feat: add ERP agent pet bridge and startup guide
This commit is contained in:
+719
@@ -0,0 +1,719 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="${1:-$(pwd)}"
|
||||
output_root="${2:-$repo_root/artifacts}"
|
||||
package_version="${LSERP_PACKAGE_VERSION:-0.4.0}"
|
||||
if [[ ! "$package_version" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$ ]]; then
|
||||
echo 'LSERP_PACKAGE_VERSION must be a three-part numeric version.' >&2
|
||||
exit 3
|
||||
fi
|
||||
dotnet6="${LSERP_DOTNET6:-dotnet}"
|
||||
dotnet8="${LSERP_DOTNET8:-dotnet}"
|
||||
python_bin="${LSERP_PYTHON:-python3}"
|
||||
pwsh_bin="${LSERP_PWSH:-pwsh}"
|
||||
npm_bin="${LSERP_NPM:-npm}"
|
||||
astrbot_contract_python="${LSERP_ASTRBOT_CONTRACT_PYTHON:-}"
|
||||
astrbot_source="${LSERP_ASTRBOT_SOURCE:-}"
|
||||
host_certificate_thumbprint="${LSERP_HOST_CERT_THUMBPRINT:-}"
|
||||
host_timestamp_url="${LSERP_HOST_TIMESTAMP_URL:-}"
|
||||
host_certificate_store="${LSERP_HOST_CERT_STORE:-CurrentUser}"
|
||||
host_signtool_path="${LSERP_HOST_SIGNTOOL:-}"
|
||||
host_signing_requested=false
|
||||
if [[ -z "$astrbot_contract_python" || ! -f "$astrbot_contract_python" || \
|
||||
-z "$astrbot_source" || ! -d "$astrbot_source" ]]; then
|
||||
echo 'Commercial packaging requires LSERP_ASTRBOT_CONTRACT_PYTHON and LSERP_ASTRBOT_SOURCE for the reviewed AstrBot 4.27.2 checkout.' >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ -n "$host_certificate_thumbprint" || -n "$host_timestamp_url" || \
|
||||
-n "$host_signtool_path" ]]; then
|
||||
if [[ ! "$host_certificate_thumbprint" =~ ^[A-Fa-f0-9]{40}$ || \
|
||||
! "$host_timestamp_url" =~ ^https:// || \
|
||||
( "$host_certificate_store" != CurrentUser && \
|
||||
"$host_certificate_store" != LocalMachine ) ]]; then
|
||||
echo 'Host signing parameters are incomplete or invalid.' >&2
|
||||
exit 3
|
||||
fi
|
||||
host_signing_requested=true
|
||||
fi
|
||||
|
||||
repo_root="$(cd "$repo_root" && pwd)"
|
||||
mkdir -p "$output_root"
|
||||
output_root="$(cd "$output_root" && pwd)"
|
||||
package_name="Lserp-AgentPet-${package_version}-win-x64"
|
||||
final_directory="$output_root/$package_name"
|
||||
final_archive="$output_root/$package_name.zip"
|
||||
if [[ -e "$final_directory" || -e "$final_archive" ]]; then
|
||||
echo "Refusing to overwrite an existing package: $package_name" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/lserp-commercial-package.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -rf -- "$temporary_root"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
logs="$temporary_root/logs"
|
||||
stage="$temporary_root/$package_name"
|
||||
mkdir -p "$logs" "$stage/Host" "$stage/AstrBotPlugin" \
|
||||
"$stage/Deployment/SqlServer" "$stage/Deployment/customer-profiles" \
|
||||
"$stage/PythonWheels" "$stage/Contracts"
|
||||
|
||||
plugin_root="$repo_root/插件库/astrbot_plugin_lserp"
|
||||
python_test_venv="$temporary_root/python-test-venv"
|
||||
python_test_wheels="$temporary_root/python-test-wheels"
|
||||
mkdir -p "$python_test_wheels"
|
||||
"$python_bin" -m pip download \
|
||||
--only-binary=:all: \
|
||||
--require-hashes \
|
||||
--dest "$python_test_wheels" \
|
||||
--requirement "$plugin_root/requirements.txt"
|
||||
"$python_bin" -m pip download \
|
||||
--only-binary=:all: \
|
||||
--platform win_amd64 \
|
||||
--require-hashes \
|
||||
--dest "$stage/PythonWheels" \
|
||||
--requirement "$plugin_root/requirements.txt"
|
||||
"$python_bin" -m venv "$python_test_venv"
|
||||
if [[ -f "$python_test_venv/bin/python" ]]; then
|
||||
test_python="$python_test_venv/bin/python"
|
||||
elif [[ -f "$python_test_venv/Scripts/python.exe" ]]; then
|
||||
test_python="$python_test_venv/Scripts/python.exe"
|
||||
else
|
||||
echo 'Isolated Python test runtime was not created.' >&2
|
||||
exit 3
|
||||
fi
|
||||
"$test_python" -m pip install \
|
||||
--no-index \
|
||||
--require-hashes \
|
||||
--find-links "$python_test_wheels" \
|
||||
--requirement "$plugin_root/requirements.txt"
|
||||
"$test_python" -m pip check
|
||||
|
||||
astrbot_contract_evidence="$logs/astrbot-runtime-contract.json"
|
||||
"$astrbot_contract_python" "$plugin_root/verify_astrbot_contract.py" \
|
||||
--astrbot-source "$astrbot_source" \
|
||||
--output "$astrbot_contract_evidence" \
|
||||
2>&1 | tee "$logs/astrbot-runtime-contract.log"
|
||||
if [[ ! -s "$astrbot_contract_evidence" ]]; then
|
||||
echo 'Actual AstrBot runtime contract evidence was not created.' >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
"$dotnet6" run --project "$repo_root/插件库/Lskj.CommandKernel.Tests/Lskj.CommandKernel.Tests.csproj" -c Release 2>&1 | tee "$logs/kernel.log"
|
||||
"$dotnet8" build \
|
||||
"$repo_root/插件库/Lskj.LegacyApiCompatibility.Tests/Lskj.LegacyApiCompatibility.Tests.csproj" \
|
||||
-c Release 2>&1 | tee "$logs/legacy-api.log"
|
||||
"$dotnet8" run \
|
||||
--project "$repo_root/插件库/Lskj.SqlContract.Tests/Lskj.SqlContract.Tests.csproj" \
|
||||
-c Release -- "$repo_root" 2>&1 | tee "$logs/sql-contract.log"
|
||||
"$dotnet8" run --project "$repo_root/插件库/Lskj.AgentPet.Host.Tests/Lskj.AgentPet.Host.Tests.csproj" -c Release 2>&1 | tee "$logs/host.log"
|
||||
(cd "$repo_root/插件库/Lskj.AgentPet" && "$npm_bin" test) 2>&1 | tee "$logs/node.log"
|
||||
(cd "$plugin_root" && "$test_python" -m unittest discover -s tests -v) 2>&1 | tee "$logs/python.log"
|
||||
if grep -Eq 'skipped=[1-9][0-9]*' "$logs/python.log"; then
|
||||
echo 'Commercial AstrBot verification must not skip worker tests.' >&2
|
||||
exit 4
|
||||
fi
|
||||
"$pwsh_bin" -NoLogo -NoProfile -File \
|
||||
"$repo_root/插件库/Lskj.AgentBridge/Deployment/CommercialPackage/Test-DeploymentContracts.ps1" \
|
||||
-RepoRoot "$repo_root" 2>&1 | tee "$logs/deployment.log"
|
||||
host_publish="$temporary_root/host-publish"
|
||||
"$dotnet8" publish \
|
||||
"$repo_root/插件库/Lskj.AgentPet.Host/Lskj.AgentPet.Host.csproj" \
|
||||
-c Release \
|
||||
-p:PublishProfile=WinX64 \
|
||||
--output "$host_publish" 2>&1 | tee "$logs/publish.log"
|
||||
|
||||
if [[ ! -s "$host_publish/Lskj.AgentPet.Host.exe" ]]; then
|
||||
echo 'Windows host publish output is missing.' >&2
|
||||
exit 3
|
||||
fi
|
||||
bridge_cli_publish="$temporary_root/bridge-cli-publish"
|
||||
"$dotnet8" publish \
|
||||
"$repo_root/插件库/Lskj.BridgeCli/Lskj.BridgeCli.csproj" \
|
||||
-c Release \
|
||||
-r win-x64 \
|
||||
--self-contained true \
|
||||
-p:Version="$package_version" \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=None \
|
||||
-p:DebugSymbols=false \
|
||||
--output "$bridge_cli_publish" 2>&1 | tee "$logs/bridge-cli-publish.log"
|
||||
if [[ ! -s "$bridge_cli_publish/lserp-agent-cli.exe" ]] || \
|
||||
find "$bridge_cli_publish" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'lserp-agent-cli.exe' | grep -q .; then
|
||||
echo 'Windows bridge-only CLI must be one self-contained executable.' >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ -e "$host_publish/lserp-agent-cli.exe" ]]; then
|
||||
echo 'Host publish unexpectedly contains the bridge CLI name.' >&2
|
||||
exit 3
|
||||
fi
|
||||
cp "$bridge_cli_publish/lserp-agent-cli.exe" "$host_publish/"
|
||||
if [[ ! -s "$host_publish/lserp-agent-cli.exe" ]]; then
|
||||
echo 'Merged commercial bridge-only CLI is missing.' >&2
|
||||
exit 3
|
||||
fi
|
||||
host_authenticode_signed=false
|
||||
if [[ "$host_signing_requested" == true ]]; then
|
||||
sign_arguments=(
|
||||
-NoLogo -NoProfile -File
|
||||
"$repo_root/插件库/Lskj.AgentBridge/Deployment/Sign-LserpAgentPetHost.ps1"
|
||||
-HostDirectory "$host_publish"
|
||||
-CertificateThumbprint "$host_certificate_thumbprint"
|
||||
-CertificateStoreLocation "$host_certificate_store"
|
||||
-TimestampUrl "$host_timestamp_url"
|
||||
)
|
||||
if [[ -n "$host_signtool_path" ]]; then
|
||||
sign_arguments+=( -SignToolPath "$host_signtool_path" )
|
||||
fi
|
||||
"$pwsh_bin" "${sign_arguments[@]}" 2>&1 | tee "$logs/host-signing.log"
|
||||
host_authenticode_signed=true
|
||||
fi
|
||||
if [[ -e "$host_publish/Web" ]] || \
|
||||
find "$host_publish" -maxdepth 1 -type f \( \
|
||||
-name 'index.html' -o -name 'pet.css' -o -name 'pet-runtime.js' \
|
||||
-o -name 'bridge-client.js' -o -name 'pet-shell.js' \) | grep -q .; then
|
||||
echo 'Commercial host publish must not contain mutable external Web assets.' >&2
|
||||
exit 3
|
||||
fi
|
||||
cp -R "$host_publish/." "$stage/Host/"
|
||||
|
||||
astrbot_plugin_files=(
|
||||
README.md
|
||||
__init__.py
|
||||
_conf_schema.json
|
||||
astrbot-contract.json
|
||||
astrbot_contract.py
|
||||
attachment_extract.py
|
||||
attachment_provenance.py
|
||||
attachment_sandbox.py
|
||||
attachment_worker.py
|
||||
bridge_protocol.py
|
||||
main.py
|
||||
metadata.yaml
|
||||
pdf_render_sandbox.py
|
||||
pdf_render_worker.py
|
||||
pdf_vision.py
|
||||
plan_chain.py
|
||||
prompt.py
|
||||
purchase_tabular_binding.py
|
||||
purchase_vision_binding.py
|
||||
requirements.txt
|
||||
session_auth.py
|
||||
tools.py
|
||||
verify_astrbot_contract.py
|
||||
verify_minimax_vlm_contract.py
|
||||
vision.py
|
||||
)
|
||||
for relative in "${astrbot_plugin_files[@]}"; do
|
||||
source="$plugin_root/$relative"
|
||||
if [[ ! -f "$source" || -L "$source" ]]; then
|
||||
echo "Required AstrBot plugin source is missing or linked: $relative" >&2
|
||||
exit 3
|
||||
fi
|
||||
cp "$source" "$stage/AstrBotPlugin/$relative"
|
||||
done
|
||||
|
||||
deployment_root="$repo_root/插件库/Lskj.AgentBridge/Deployment"
|
||||
cp "$deployment_root/CUSTOMER_ACCEPTANCE.md" "$stage/Deployment/"
|
||||
cp "$deployment_root/WRITE_ACCEPTANCE.md" "$stage/Deployment/"
|
||||
cp "$deployment_root/DYNAMIC_MODULE_WRITE_ACCEPTANCE.md" "$stage/Deployment/"
|
||||
cp "$deployment_root/FIELD_VALIDATION_RUNBOOK.md" "$stage/Deployment/"
|
||||
cp "$deployment_root/THIRD_PARTY_COMPLIANCE.md" "$stage/Deployment/"
|
||||
cp "$deployment_root/guga-upstream-audit.v1.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-WorkflowAcceptanceEvidence.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-DynamicModuleWriteAcceptance.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/dynamic-module-write-modules.example.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-DynamicModuleUpdateAcceptance.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/dynamic-module-update-modules.example.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-WorkflowWriteCasesTemplate.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-WorkflowWriteIntegrationEvidence.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Invoke-WorkflowWriteCaseCapture.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Invoke-LserpFieldReadOnlyValidation.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/field-readonly-validation.example.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/Invoke-LserpReadOnlySessionPreflight.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Invoke-LserpSelectOnlyCatalogSnapshot.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Invoke-LserpSelectOnlyProfilePreflight.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-WorkflowUatAuthorization.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-WorkflowWriteUatCampaign.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Test-WorkflowWriteUatCampaign.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/workflow-write-uat-case-catalog.v1.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/New-CustomerAcceptanceBundle.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Build-LegacyErpAcceptance.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/Sign-LserpAgentPetHost.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/CommercialPackage/Test-DeploymentContracts.ps1" "$stage/Deployment/"
|
||||
cp "$deployment_root/business-adapters.example.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/command-rollout.example.json" "$stage/Deployment/"
|
||||
cp "$deployment_root/SqlServer/"*.sql "$stage/Deployment/SqlServer/"
|
||||
cp "$deployment_root/customer-profiles/"*.json "$stage/Deployment/customer-profiles/"
|
||||
cp "$deployment_root/customer-profiles/"*.sql "$stage/Deployment/customer-profiles/"
|
||||
cp "$deployment_root/customer-profiles/README.md" "$stage/Deployment/customer-profiles/"
|
||||
cp "$deployment_root/CommercialPackage/README.md" "$stage/README.md"
|
||||
cp "$deployment_root/CommercialPackage/Start-LserpAgentPet.ps1" "$stage/Start-LserpAgentPet.ps1"
|
||||
cp "$deployment_root/CommercialPackage/Verify-LserpCommercialPackage.ps1" "$stage/Verify-LserpCommercialPackage.ps1"
|
||||
cp "$repo_root/插件库/Contracts/erp-agent-wire-contract-v1.json" "$stage/Contracts/"
|
||||
|
||||
# The package version is also the version reported by the bundled bridge CLI.
|
||||
# Keep customer-facing runbooks and handoff templates bound to that exact
|
||||
# version; a source checkout may be used to produce more than one package
|
||||
# version, so these documents must not carry a stale release number.
|
||||
"$python_bin" - "$stage" "$package_version" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
stage = pathlib.Path(sys.argv[1])
|
||||
package_version = sys.argv[2]
|
||||
marker = "@LSERP_PACKAGE_VERSION@"
|
||||
relative_paths = [
|
||||
"README.md",
|
||||
"Deployment/CUSTOMER_ACCEPTANCE.md",
|
||||
"Deployment/WRITE_ACCEPTANCE.md",
|
||||
"Deployment/field-readonly-validation.example.json",
|
||||
]
|
||||
for relative in relative_paths:
|
||||
path = stage / relative
|
||||
if not path.is_file():
|
||||
raise SystemExit("package version binding source is missing: " + relative)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if marker not in text:
|
||||
raise SystemExit("package version marker is missing: " + relative)
|
||||
rendered = text.replace(marker, package_version)
|
||||
if marker in rendered:
|
||||
raise SystemExit("package version marker was not fully rendered: " + relative)
|
||||
path.write_text(rendered, encoding="utf-8")
|
||||
|
||||
template = json.loads(
|
||||
(stage / "Deployment/field-readonly-validation.example.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
expected_cli_version = template.get("cli", {}).get("version")
|
||||
expected_cli_path = template.get("cli", {}).get("path")
|
||||
if expected_cli_version != package_version or package_version not in expected_cli_path:
|
||||
raise SystemExit("rendered field validation template is not package-version bound")
|
||||
PY
|
||||
|
||||
git_commit="unknown"
|
||||
worktree_dirty=true
|
||||
if git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git_commit="$(git -C "$repo_root" rev-parse HEAD)"
|
||||
if [[ -z "$(git -C "$repo_root" status --porcelain)" ]]; then
|
||||
worktree_dirty=false
|
||||
fi
|
||||
fi
|
||||
export LSERP_STAGE="$stage"
|
||||
export LSERP_LOGS="$logs"
|
||||
export LSERP_PACKAGE_VERSION_VALUE="$package_version"
|
||||
export LSERP_GIT_COMMIT_VALUE="$git_commit"
|
||||
export LSERP_WORKTREE_DIRTY_VALUE="$worktree_dirty"
|
||||
export LSERP_HOST_AUTHENTICODE_SIGNED_VALUE="$host_authenticode_signed"
|
||||
export LSERP_HOST_CERT_THUMBPRINT_VALUE="$host_certificate_thumbprint"
|
||||
export LSERP_ASTRBOT_CONTRACT_EVIDENCE="$astrbot_contract_evidence"
|
||||
"$python_bin" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
stage = pathlib.Path(os.environ["LSERP_STAGE"])
|
||||
logs = pathlib.Path(os.environ["LSERP_LOGS"])
|
||||
|
||||
def parse_pair(name: str) -> tuple[int, int]:
|
||||
text = (logs / name).read_text(encoding="utf-8", errors="replace")
|
||||
matches = re.findall(r"passed=(\d+) failed=(\d+)", text)
|
||||
if not matches:
|
||||
raise SystemExit(f"missing test summary in {name}")
|
||||
return tuple(map(int, matches[-1]))
|
||||
|
||||
kernel = parse_pair("kernel.log")
|
||||
host = parse_pair("host.log")
|
||||
deployment = parse_pair("deployment.log")
|
||||
sql_contract = parse_pair("sql-contract.log")
|
||||
node_text = (logs / "node.log").read_text(encoding="utf-8", errors="replace")
|
||||
python_text = (logs / "python.log").read_text(encoding="utf-8", errors="replace")
|
||||
sql_contract_text = (logs / "sql-contract.log").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
node_match = re.findall(
|
||||
r"(?m)^\s*(?:#|ℹ)\s+tests\s+(\d+)\s*$",
|
||||
node_text,
|
||||
)
|
||||
python_match = re.findall(r"Ran (\d+) tests", python_text)
|
||||
if not node_match or not python_match:
|
||||
raise SystemExit("missing Node or Python test summary")
|
||||
python_skipped_match = re.findall(r"skipped=(\d+)", python_text)
|
||||
python_skipped = int(python_skipped_match[-1]) if python_skipped_match else 0
|
||||
if python_skipped != 0:
|
||||
raise SystemExit("commercial AstrBot verification contains skipped tests")
|
||||
|
||||
astrbot_contract = json.loads(
|
||||
pathlib.Path(os.environ["LSERP_ASTRBOT_CONTRACT_EVIDENCE"]).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
expected_astrbot_contract_keys = {
|
||||
"schemaVersion",
|
||||
"passed",
|
||||
"repository",
|
||||
"sourceTag",
|
||||
"sourceCommit",
|
||||
"runtimeVersion",
|
||||
"versionSpecifier",
|
||||
"pluginVersion",
|
||||
"licenseExpression",
|
||||
"licenseSha256",
|
||||
"eulaSha256",
|
||||
"criticalSourceFilesVerified",
|
||||
"registeredTools",
|
||||
}
|
||||
if (
|
||||
not isinstance(astrbot_contract, dict)
|
||||
or set(astrbot_contract) != expected_astrbot_contract_keys
|
||||
or astrbot_contract["schemaVersion"] != "1.1"
|
||||
or astrbot_contract["passed"] is not True
|
||||
or astrbot_contract["repository"]
|
||||
!= "https://github.com/AstrBotDevs/AstrBot.git"
|
||||
or astrbot_contract["sourceTag"] != "v4.27.2"
|
||||
or astrbot_contract["sourceCommit"]
|
||||
!= "ad4fbfa90ca0c4ac2b30b3250e34dbf8fe7babbf"
|
||||
or astrbot_contract["runtimeVersion"] != "4.27.2"
|
||||
or astrbot_contract["versionSpecifier"] != "==4.27.2"
|
||||
or astrbot_contract["pluginVersion"] != "0.4.0"
|
||||
or astrbot_contract["licenseExpression"] != "AGPL-3.0-or-later"
|
||||
or astrbot_contract["licenseSha256"]
|
||||
!= "ccf7d08f932af3e813848881731113afbb7c80d0fd6d958e8d319002bf344d02"
|
||||
or astrbot_contract["eulaSha256"]
|
||||
!= "c332de7781e87c67d6d3beda463fa04705075a6bae9e52a252f7c639f6defd80"
|
||||
or astrbot_contract["criticalSourceFilesVerified"] != 20
|
||||
or astrbot_contract["registeredTools"]
|
||||
!= ["erp_get_context", "erp_get_capabilities", "erp_plan_command"]
|
||||
):
|
||||
raise SystemExit("actual AstrBot runtime contract evidence is invalid")
|
||||
|
||||
guga_audit_path = stage / "Deployment" / "guga-upstream-audit.v1.json"
|
||||
guga_audit_bytes = guga_audit_path.read_bytes()
|
||||
guga_audit = json.loads(guga_audit_bytes.decode("utf-8"))
|
||||
asset = guga_audit.get("asset") if isinstance(guga_audit, dict) else None
|
||||
installer = guga_audit.get("installer") if isinstance(guga_audit, dict) else None
|
||||
service_source = (
|
||||
guga_audit.get("serviceSource") if isinstance(guga_audit, dict) else None
|
||||
)
|
||||
commercial_decision = (
|
||||
guga_audit.get("commercialDecision")
|
||||
if isinstance(guga_audit, dict)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
not isinstance(guga_audit, dict)
|
||||
or set(guga_audit) != {
|
||||
"schemaVersion",
|
||||
"auditedAtUtc",
|
||||
"asset",
|
||||
"installer",
|
||||
"serviceSource",
|
||||
"commercialDecision",
|
||||
}
|
||||
or guga_audit["schemaVersion"] != "1.0"
|
||||
or guga_audit["auditedAtUtc"] != "2026-08-13T19:31:24+00:00"
|
||||
or not isinstance(asset, dict)
|
||||
or set(asset)
|
||||
!= {
|
||||
"id",
|
||||
"displayName",
|
||||
"ownerHandle",
|
||||
"ownerName",
|
||||
"uploadedAtUtc",
|
||||
"shareUrl",
|
||||
"shareDataUrl",
|
||||
"downloadUrl",
|
||||
"packageSha256",
|
||||
"packageSizeBytes",
|
||||
"manifestSha256",
|
||||
"spriteSha256",
|
||||
"spriteSizeBytes",
|
||||
"atlasSize",
|
||||
"licenseMetadataPresent",
|
||||
"licenseFilePresent",
|
||||
}
|
||||
or asset["id"] != "guga"
|
||||
or asset["ownerHandle"] != "circus"
|
||||
or asset["shareUrl"] != "https://codex-pets.net/share/guga"
|
||||
or asset["packageSha256"]
|
||||
!= "3ebd971ba59a0c988a6be0924669b4c5db9234bcc5d17d506e34eba332e6021f"
|
||||
or asset["packageSizeBytes"] != 1946012
|
||||
or asset["manifestSha256"]
|
||||
!= "f9f715811c26ca610764a7698e28f2e182882f097f4c60a3f00a79dd7530bd20"
|
||||
or asset["spriteSha256"]
|
||||
!= "1b61ea2af98717b9ebe55beb4c6b820b89e9c42d4fdfeca21cf63ed3ad4e38da"
|
||||
or asset["spriteSizeBytes"] != 1945586
|
||||
or asset["atlasSize"] != "1536x1872"
|
||||
or asset["licenseMetadataPresent"] is not False
|
||||
or asset["licenseFilePresent"] is not False
|
||||
or not isinstance(installer, dict)
|
||||
or set(installer)
|
||||
!= {
|
||||
"packageName",
|
||||
"version",
|
||||
"registryUrl",
|
||||
"tarballUrl",
|
||||
"tarballSha1",
|
||||
"tarballSha256",
|
||||
"npmIntegrity",
|
||||
"declaredLicense",
|
||||
"repositoryDeclared",
|
||||
"defaultApiBase",
|
||||
"installRoot",
|
||||
"writtenFiles",
|
||||
"assetDigestVerification",
|
||||
"assetSignatureVerification",
|
||||
"assetLicenseVerification",
|
||||
}
|
||||
or installer["packageName"] != "codex-pets"
|
||||
or installer["version"] != "0.3.0"
|
||||
or installer["tarballSha1"] != "82e41349ae63eb9e63099f2e06a56468182e2c90"
|
||||
or installer["tarballSha256"]
|
||||
!= "9ec8bf1ea09e6d8fdc17b33a594a178a9b20bd3dc6decbb22973758394c9c1c7"
|
||||
or installer["npmIntegrity"]
|
||||
!= "sha512-b7PjV0phEK7jn0rnyXzh3LMIsAdqSUf75mCdSxZEyIuFScCxwOeUZoDxnWj97rfg4ihk6XLaKvg6fgWD+CWcAQ=="
|
||||
or installer["declaredLicense"] != "MIT"
|
||||
or installer["repositoryDeclared"] is not False
|
||||
or installer["installRoot"] != "$CODEX_HOME/pets/{pet-id}"
|
||||
or installer["writtenFiles"] != ["pet.json", "spritesheet.webp"]
|
||||
or installer["assetDigestVerification"] is not False
|
||||
or installer["assetSignatureVerification"] is not False
|
||||
or installer["assetLicenseVerification"] is not False
|
||||
or not isinstance(service_source, dict)
|
||||
or set(service_source)
|
||||
!= {
|
||||
"repository",
|
||||
"commit",
|
||||
"softwareLicense",
|
||||
"licenseSha256",
|
||||
"termsSourceSha256",
|
||||
"termsEffectiveDate",
|
||||
"uploadTermsScope",
|
||||
}
|
||||
or service_source["repository"]
|
||||
!= "https://github.com/portons/codex-pet-share"
|
||||
or service_source["commit"]
|
||||
!= "22725091da2787e8e525c9289cb7826a34be4950"
|
||||
or service_source["softwareLicense"] != "MIT"
|
||||
or service_source["licenseSha256"]
|
||||
!= "13e779572adacb503b7e7a0c676571fcd86114a73f6aa000412c24a9a06a97d3"
|
||||
or service_source["termsSourceSha256"]
|
||||
!= "70ad12414864566b8cd469a7d2ca39fe60050686cecacc126ff1aca587f790bb"
|
||||
or service_source["uploadTermsScope"] != "public-sharing-through-service"
|
||||
or not isinstance(commercial_decision, dict)
|
||||
or set(commercial_decision)
|
||||
!= {"status", "code", "reason", "requiredEvidence"}
|
||||
or commercial_decision["status"] != "external-license-required"
|
||||
or commercial_decision["code"] != "guga_commercial_license_missing"
|
||||
or commercial_decision["requiredEvidence"]
|
||||
!= [
|
||||
"rights-holder-identity",
|
||||
"commercial-product-use",
|
||||
"customer-deployment-and-copying",
|
||||
"product-display",
|
||||
"territory-and-term",
|
||||
"asset-sha256-binding",
|
||||
"authorized-legal-review",
|
||||
]
|
||||
):
|
||||
raise SystemExit("guga upstream supply-chain audit is invalid")
|
||||
guga_audit_sha256 = hashlib.sha256(guga_audit_bytes).hexdigest()
|
||||
|
||||
banned_guga_hashes = {
|
||||
asset["packageSha256"],
|
||||
asset["manifestSha256"],
|
||||
asset["spriteSha256"],
|
||||
}
|
||||
for path in stage.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
lower_name = path.name.lower()
|
||||
if lower_name == "spritesheet.webp" or lower_name.endswith(".codex-pet.zip"):
|
||||
raise SystemExit(
|
||||
f"external guga asset must not be bundled: {path.relative_to(stage)}"
|
||||
)
|
||||
if path.stat().st_size <= 20 * 1024 * 1024:
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if digest in banned_guga_hashes:
|
||||
raise SystemExit(
|
||||
f"external guga asset bytes must not be bundled: {path.relative_to(stage)}"
|
||||
)
|
||||
|
||||
minimum_pass_counts = {
|
||||
"commandKernel": 284,
|
||||
"desktopHost": 50,
|
||||
"petWebUi": 24,
|
||||
"astrbotPlugin": 96,
|
||||
"deploymentContracts": 76,
|
||||
"sqlServerCompatibility100": 16,
|
||||
}
|
||||
actual_pass_counts = {
|
||||
"commandKernel": kernel[0],
|
||||
"desktopHost": host[0],
|
||||
"petWebUi": int(node_match[-1]),
|
||||
"astrbotPlugin": int(python_match[-1]),
|
||||
"deploymentContracts": deployment[0],
|
||||
"sqlServerCompatibility100": sql_contract[0],
|
||||
}
|
||||
if kernel[1] != 0 or host[1] != 0 or deployment[1] != 0 or sql_contract[1] != 0:
|
||||
raise SystemExit("commercial verification contains failed tests")
|
||||
if (
|
||||
"parserPackage=Microsoft.SqlServer.TransactSql.ScriptDom "
|
||||
"parserVersion=180.59.2 dialect=TSql100"
|
||||
) not in sql_contract_text:
|
||||
raise SystemExit("commercial SQL Server compatibility parser metadata is invalid")
|
||||
for suite, minimum in minimum_pass_counts.items():
|
||||
if actual_pass_counts[suite] < minimum:
|
||||
raise SystemExit(
|
||||
f"commercial verification test baseline regressed: {suite}"
|
||||
)
|
||||
|
||||
report = {
|
||||
"schemaVersion": "1.1",
|
||||
"packageVersion": os.environ["LSERP_PACKAGE_VERSION_VALUE"],
|
||||
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"sourceCommit": os.environ["LSERP_GIT_COMMIT_VALUE"],
|
||||
"sourceWorktreeDirty": os.environ["LSERP_WORKTREE_DIRTY_VALUE"] == "true",
|
||||
"deliveryTopology": {
|
||||
"desktopBundleContainsLserpCli": False,
|
||||
"desktopBundleContainsBridgeCli": True,
|
||||
"bridgeCliPath": "Host/lserp-agent-cli.exe",
|
||||
"bridgeCliDatabaseDirectAccess": False,
|
||||
"bridgeCliPublishMode": "win_x64_single_file_self_contained",
|
||||
"desktopBundleContainsLegacyErp": False,
|
||||
"legacyArtifactMode": "separate_signed_windows_build",
|
||||
"legacyBuildTool": "Deployment/Build-LegacyErpAcceptance.ps1",
|
||||
"runtimeRequiresLegacyArtifact": True,
|
||||
},
|
||||
"automatedVerification": {
|
||||
"commandKernel": {"passed": actual_pass_counts["commandKernel"], "failed": kernel[1]},
|
||||
"desktopHost": {"passed": actual_pass_counts["desktopHost"], "failed": host[1]},
|
||||
"petWebUi": {"passed": actual_pass_counts["petWebUi"], "failed": 0},
|
||||
"astrbotPlugin": {
|
||||
"passed": actual_pass_counts["astrbotPlugin"],
|
||||
"failed": 0,
|
||||
"skipped": python_skipped,
|
||||
},
|
||||
"astrbotRuntimeContract": astrbot_contract,
|
||||
"miniMaxVision": {
|
||||
"mode": "direct_https_vlm",
|
||||
"allowedRegions": ["global", "cn"],
|
||||
"bundledCli": False,
|
||||
},
|
||||
"gugaSupplyChainAudit": {
|
||||
"passed": True,
|
||||
"auditFile": "Deployment/guga-upstream-audit.v1.json",
|
||||
"auditSha256": guga_audit_sha256,
|
||||
"assetId": "guga",
|
||||
"installerPackage": "codex-pets",
|
||||
"installerVersion": "0.3.0",
|
||||
"installerTarballSha256": installer["tarballSha256"],
|
||||
"observedSpriteSha256": asset["spriteSha256"],
|
||||
"upstreamCommercialLicensePresent": False,
|
||||
"assetBundled": False,
|
||||
},
|
||||
"deploymentContracts": {
|
||||
"passed": actual_pass_counts["deploymentContracts"],
|
||||
"failed": deployment[1],
|
||||
},
|
||||
"sqlServerCompatibility100": {
|
||||
"passed": actual_pass_counts["sqlServerCompatibility100"],
|
||||
"failed": sql_contract[1],
|
||||
"parserPackage": "Microsoft.SqlServer.TransactSql.ScriptDom",
|
||||
"parserVersion": "180.59.2",
|
||||
"dialect": "TSql100",
|
||||
},
|
||||
"legacyNet40ApiCompile": True,
|
||||
"winX64SelfContainedPublish": True,
|
||||
"bridgeCliWinX64SelfContainedPublish": True,
|
||||
"hostAuthenticode": {
|
||||
"signed": os.environ["LSERP_HOST_AUTHENTICODE_SIGNED_VALUE"] == "true",
|
||||
"certificateThumbprint": (
|
||||
os.environ["LSERP_HOST_CERT_THUMBPRINT_VALUE"].upper() or None
|
||||
),
|
||||
},
|
||||
},
|
||||
"releaseReadiness": False,
|
||||
"remainingHardGates": [
|
||||
"clean reviewed source commit",
|
||||
"valid Windows Authenticode signature and signed installer",
|
||||
"written commercial license for the external guga artwork",
|
||||
"approved AstrBot AGPL-3.0-or-later and EULA compliance plan",
|
||||
"approved MiniMax API service terms, data processing, deployment region and billing plan",
|
||||
"customer Windows/.NET Framework 4/DevExpress 15.2 integration",
|
||||
"customer SQL Server transactional write, rollback, idempotency and audit evidence",
|
||||
"signed workflow acceptance manifests bound to final business-adapters.json",
|
||||
],
|
||||
}
|
||||
(stage / "BUILD-VERIFICATION.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
secret_patterns = [
|
||||
re.compile("sk" + r"-cp-[A-Za-z0-9_-]{20,}"),
|
||||
re.compile(r"(?i)(?:api[_-]?key|password)\s*[=:]\s*['\"][A-Za-z0-9_-]{16,}['\"]"),
|
||||
]
|
||||
for path in stage.rglob("*"):
|
||||
if not path.is_file() or path.stat().st_size > 2 * 1024 * 1024:
|
||||
continue
|
||||
if path.suffix.lower() not in {".json", ".yaml", ".yml", ".md", ".ps1", ".py", ".js", ".sql", ".txt"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if any(pattern.search(text) for pattern in secret_patterns):
|
||||
raise SystemExit(f"possible secret in staged text file: {path.relative_to(stage)}")
|
||||
|
||||
files = []
|
||||
for path in sorted(stage.rglob("*"), key=lambda item: item.as_posix()):
|
||||
if not path.is_file() or path.name == "SHA256SUMS.json":
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
files.append({
|
||||
"path": path.relative_to(stage).as_posix(),
|
||||
"sizeBytes": path.stat().st_size,
|
||||
"sha256": digest,
|
||||
})
|
||||
manifest = {
|
||||
"schemaVersion": "1.0",
|
||||
"packageVersion": os.environ["LSERP_PACKAGE_VERSION_VALUE"],
|
||||
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"files": files,
|
||||
}
|
||||
(stage / "SHA256SUMS.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
PY
|
||||
|
||||
archive_base="$temporary_root/$package_name"
|
||||
"$python_bin" - "$stage" "$archive_base" <<'PY'
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
stage = pathlib.Path(sys.argv[1])
|
||||
archive_base = pathlib.Path(sys.argv[2])
|
||||
shutil.make_archive(str(archive_base), "zip", root_dir=stage.parent, base_dir=stage.name)
|
||||
PY
|
||||
|
||||
mv "$stage" "$final_directory"
|
||||
mv "$archive_base.zip" "$final_archive"
|
||||
"$python_bin" - "$final_archive" <<'PY'
|
||||
import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
print(f"package={path}")
|
||||
print(f"sizeBytes={path.stat().st_size}")
|
||||
print(f"sha256={hashlib.sha256(path.read_bytes()).hexdigest()}")
|
||||
PY
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
# 客户环境商用验收门禁
|
||||
|
||||
桥请求会话合同:CLI、Host 和 AstrBot 必须把数据库作用域指纹、用户、用户名、账套、子系统和管理员状态绑定为 v3 `sessionScopeToken`;能力、计划和执行请求缺失或不匹配时,ERP 服务端必须在任何业务查询/写入前拒绝。该令牌不是密码,也不能由模型生成或覆盖。
|
||||
|
||||
本文档是把当前 fail-closed 框架接入某个客户低代码配置的必做清单。未完成任何一项时,`purchase.invoice.create`、`hr.leave.resolve`、`hr.leave.create` 和 `hr.leave.submit` 必须保持未注册,不允许通过改 `enabled=true` 绕过。
|
||||
|
||||
## 0. 旧 ERP 与 CLI 构建取证
|
||||
|
||||
禁止直接使用共享 `Release` 目录中的历史二进制。先从经过代码审查、无未提交文件且与桌宠包 `BUILD-VERIFICATION.json/sourceCommit` 相同的提交运行 `Build-LegacyErpAcceptance.ps1`。输出目录必须是新目录;同时提供经过制品库审批、与现有 `Xilium.CefGlue 87.1.1` 配套的 `cef.redist.x86.87.1.13.nupkg`,其 SHA-256 必须为 `34dfe2504c1ffaef02eab1f38578701b045439349997b6465fd5dd6659fab021`。脚本会创建提交级隔离克隆,按 `Release|Mixed Platforms` 的解决方案映射重建 `lserp-cli`、`Ls_ERP`、`Lskj.AgentBridge`、`Lskj.CommandKernel` 及依赖,复制并逐哈希复核完整原生 CEF 运行时,并验证 .NET Framework 4、最终 EXE 的 x86/`32BITREQUIRED`、ERP `LargeAddressAware`、DevExpress 15.2 和固定依赖契约。脚本还会在任何 Authenticode 签名前扫描 `Ls_ERP.exe`、`lserp-cli.exe` 和顶层 `Lskj.*.dll` 的 ASCII/UTF-16 字符视图;发现成对硬编码 SQL 用户名/口令时只返回 `legacy_runtime_hardcoded_sql_credential:<文件名>` 并终止,不回显凭据值。该门禁不代替客户制品库的全量秘密扫描、SBOM 和恶意软件检查。CEF 87 仅是遗留兼容基线;完成受支持版本升级或客户安全负责人书面风险接受前,禁止该内嵌浏览器访问公网及其他不受信任页面。
|
||||
|
||||
最终构建必须向脚本提供客户发布证书指纹和 HTTPS RFC3161 时间戳地址,由脚本在生成哈希证据前签署 `Ls_ERP.exe`、`lserp-cli.exe`、AgentBridge、CommandKernel 和 Core。桌宠侧还必须用同一客户批准证书签署 `Lskj.AgentPet.Host.exe`、`Lskj.AgentPet.Host.dll` 与 `Lskj.AgentPet.Host.Core.dll`,预检与启动时通过 `-HostCertificateThumbprint` 固定预期签发者。保留 `LEGACY-BUILD-EVIDENCE.json`、`MSBUILD.log`、签名后全部逐文件哈希、安装包哈希和回滚版本;证据生成后不得再修改 Runtime 文件。`Verify-LserpCommercialPackage.ps1` 必须同时传入最终 ZIP 的 `-PackageArchivePath`、解包目录、构建目录、证书指纹、`-RolloutPolicyPath` 和 `-RolloutCustomerId` 并通过;报告中的 `package_archive_binding` 必须证明 ZIP 与解包目录同属一份逐文件清单,`erp_rollout_policy` 则证明目标 ERP 进程实际加载的发布策略哈希和客户 ID 与现场文件一致。构建通过只证明产物契约,不证明客户事务、权限或工作流正确。
|
||||
|
||||
## 1. 环境与秘密
|
||||
|
||||
商用桥启动前必须先完成命令发布范围签收。把 `command-rollout.example.json` 复制到安装包外的客户受控目录,保持 `schemaVersion=1.1` 和 `defaultAction=deny`,把根级 `databaseScopeFingerprint` 替换为本次人工核准的当前数据库作用域,只列出本客户已经注册且获准发布的命令;每条规则精确绑定命令版本、原 ERP `requiredPermission`、账套、子系统及 `all_authorized`、精确内置管理员或精确用户。旧 ERP 的 `GroupId` 只是菜单分组,不是可信角色 ID,不能写入自创“角色映射”扩大权限。最终文件只允许部署人员修改、ERP 用户读取,且不得是链接。以下变量必须由批准的启动器在启动签名 ERP 进程前同时注入;SHA-256 针对文件原始字节,客户部署标识是制品系统分配的非秘密稳定 ID:
|
||||
|
||||
```powershell
|
||||
$rolloutPath = 'C:\ProgramData\Langsu\AgentBridge\command-rollout.json'
|
||||
$rolloutSha256 = (Get-FileHash -LiteralPath $rolloutPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$env:LSERP_AGENT_ROLLOUT_CONFIG = $rolloutPath
|
||||
$env:LSERP_AGENT_ROLLOUT_SHA256 = $rolloutSha256
|
||||
$env:LSERP_AGENT_ROLLOUT_CUSTOMER_ID = 'CUSTOMER-001'
|
||||
$env:LSERP_AGENT_BRIDGE_ENABLED = '1'
|
||||
# 从这个已批准的启动器进程启动最终签名的 Ls_ERP.exe。
|
||||
```
|
||||
|
||||
- 只在客户 Windows 10/11 测试终端和可恢复的 SQL Server 验收库上执行写入测试。
|
||||
- ERP、AgentBridge、AstrBot 同机模式必须运行在同一个 Windows 用户边界;当前宿主、商用启动器和预检只接受 loopback 地址。中央 AstrBot 必须先实现客户端主动出站、双向设备身份的 Agent Gateway,不能暴露命名管道;Gateway 尚未交付前,任何远程 HTTP/HTTPS 地址都必须失败关闭。
|
||||
- 在 AstrBot 专用服务账号中用轮换后的 MiniMax Key 运行包内 `AstrBotPlugin\verify_minimax_vlm_contract.py`,区域必须与采购套餐一致。探针只能发送内置无客户数据图片,报告必须显示官方 0.0.4 合同提交、`MM-API-Source`、固定端点和 `passed=true`;将报告 SHA-256 写入法务/安全复核的 MiniMax 服务证据。不得使用聊天中曾暴露的 Key,也不得用真实客户发票代替合成探针。
|
||||
- 由受控管理员只在隔离验收进程中读取实际 `lserp.agent.<PID>.<bridgeInstanceId>` 管道名,再让同机第二个普通 Windows 用户直接连接,并从另一台电脑以与 ERP 相同的域账号通过远程命名管道连接;两者都必须在发送协议帧前得到访问拒绝,ERP 不得产生桥审计、计划或确认框。不得退化回可预测的无代际 `lserp.agent.<PID>`。同机 ERP 用户及 LocalSystem 的受控健康检查必须成功;用 ACL 检查工具确认保护继承已开启、Owner 为启动 ERP 的用户、存在 `NetworkSid/AnonymousSid` 拒绝,且不存在 Everyone/Authenticated Users 允许项。
|
||||
- 在 ERP 同一 Windows 用户下先备份并替换发现文件,让测试进程创建相同协议名的假管道,同时让真实 ERP PID 和启动时间仍保持有效。最终签名 CLI、桌宠宿主和 AstrBot Tool 都必须在发送任何请求字节前返回 `bridge_server_identity_mismatch`,假服务端观察到的请求字节数必须为 0,ERP 不得产生计划、确认框或业务审计;恢复原子发布的真实发现文件后,三条链的只读健康检查必须成功。
|
||||
- 在保持同一个 ERP EXE/PID 和进程启动时间的条件下,先生成一个只读计划,再模拟退出登录或调用桥停止/重建。新发现文件的 32 位小写十六进制 `bridgeInstanceId` 必须与旧值不同,新管道必须从 `lserp.agent.<PID>.<旧bridgeInstanceId>` 变为 `lserp.agent.<PID>.<新bridgeInstanceId>`;把旧发现文件保留到新桥开始监听但新发现文件尚未发布的窗口,旧客户端只能得到连接失败,不能进入新桥。独立 CLI 重新派生的 v2 `clientSessionId` 必须变化,旧计划不能执行。保持旧 `clientSessionId` 的桌宠 Host 与 AstrBot Tool 必须在打开新管道或发送业务请求前返回 `erp_bridge_instance_changed`,且零确认、零数据库调用、零完成审计;关闭旧桌宠并通过启动器创建全新会话后,只读健康检查才恢复。发现代际不得进入模型上下文、业务预览或验收报告。
|
||||
- 客户终端无论单开或多开 ERP,桌宠都必须由 `Start-LserpAgentPet.ps1` 启动并生成 `lserp-pet-p<PID>-s<启动Unix秒>-c<32位会话作用域令牌>-<32位随机数>` 会话;作用域令牌必须按固定合同共同绑定人工核准的数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值。宿主、AstrBot Prompt 和三个 ERP Tool 必须拒绝缺 PID、缺启动时间/作用域令牌或仅匹配前缀的旧式会话。宿主直连桥和三个 Tool 还必须在目标操作前后执行严格 `context.get`,初始不符以 `erp_session_scope_mismatch` 阻断,期间切换以 `erp_session_scope_changed` 丢弃结果。商用预检、桌宠启动以及同一组 CLI `bridge plan/execute` 必须使用相同的 PID、数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值;受限 CLI 的每个命令都必须收到六项显式预期范围参数,并独立执行初始范围校验及目标调用前后复核。预检会用最终签名 CLI 实际执行只读 `bridge health`;未指定目标、缺少任一范围参数或目标启动实例变化时必须失败关闭,禁止按发现文件时间自动切换账套。
|
||||
- ERP 内部数据库执行边界也必须独立复核同一作用域:采购/请假读写和通用低代码 Lookup/新增/更新在过程调用前后,以及任何成功写事务 `Commit` 前,都要把请求中的用户(就绪查询可省略用户)、账套、子系统、数据库指纹与当前 `ERPInfo`、同一个 `SqlHelper._connection` 实例、实际服务器和数据库逐字比对。现场验收至少要覆盖切换用户/账套/子系统、替换连接实例、同名库换服务器和连接中途换库的负例;读取必须丢弃,写入必须回滚并返回 `erp_session_scope_changed` 或 `erp_database_session_changed`,不能仅依赖桌宠调用后的 `context.get`。
|
||||
- 在同一 ERP 登录会话分别保持配置端点不变但替换到另一台服务器上的同名验收库、保持实际连接不变但修改配置端点,以及重新登录原库三种场景。前两种场景必须因“配置端点 + `DbConnection.DataSource` 实际端点 + 实际数据库 + 提供者”作用域指纹变化而拒绝旧计划、旧验收证据和旧幂等重放;重新登录原库只能重新规划,不能复用重启前计划。任何响应、模型上下文和日志都不得回显服务器、库名或连接串。
|
||||
- 分别验证缺少三个发布变量中的任意一个、篡改配置一个字节但沿用旧 SHA-256、客户 ID 不一致、`defaultAction=allow`、JSON 含注释/重复/未知字段、规则引用未注册命令、命令版本或 `requiredPermission` 漂移;每个负例都必须允许普通 ERP 继续运行但不产生 AgentBridge 发现文件。正例启动后,以范围内/外账套、子系统、普通用户、同名假管理员和精确内置管理员检查 capabilities;范围外命令必须隐藏,直接 plan/已有 plan 的执行复核必须返回 `command_rollout_denied` 且零确认/零数据库调用。修改文件或环境变量在旧进程中不得热生效,重启后旧计划必须消失。
|
||||
- 使用最终签名 CLI 对精确 ERP PID 执行 `bridge health`,核对 `rolloutPolicy.configured=true`、`failClosed=true`、`defaultAction=deny`、客户 ID、数据库作用域指纹、规则数和现场文件 SHA-256。该对象只能有这七个字段,不得返回配置路径、账套、子系统、用户、权限或规则内容。随后给商用预检传入同一文件、客户 ID 和完整预期会话,`erp_rollout_policy` 与 `erp_session_scope` 必须通过;换发布文件、数据库、用户、账套、子系统、客户 ID 或 ERP PID 都必须失败关闭,且桌宠不得启动。
|
||||
- 连续冷启动 ERP 20 次,在每次发现文件出现后立即执行绑定该进程的 `bridge health`,不得出现发现文件已发布但监听尚未就绪。再让 4 个本地测试客户端只连接不发送,确认 15 秒后全部连接槽恢复;关闭 ERP 时接收线程必须在 2 秒内退出。发送畸形 UTF-8 帧必须得到 `invalid_utf8` 或被安全断开,且不得产生业务计划、确认框、数据库调用或残留发现文件。
|
||||
- 在验收 ERP 启动前设置 `LSERP_AGENT_DISABLED_COMMANDS=module.navigate`,确认该命令不出现在 capabilities,直接规划返回 `command_disabled` 且零确认/零数据库调用;清除后重启才恢复。再填入一个格式合法但当前未注册的命令名,桥也必须拒绝发布,不能静默忽略拼写错误。分别把每分钟限流设为 2、会话状态容量设为 2,验证第三个同会话请求返回 `bridge_rate_limit_exceeded`、第三个并行会话返回 `bridge_rate_state_capacity_exceeded`,一分钟/五分钟边界后恢复。再让一个受控只读测试命令连续返回白名单 `workflow_database_error` 达到阈值,确认命令从 capabilities 隐藏并返回 `command_circuit_open`;输入错误、权限拒绝和用户取消不得触发熔断,冷却后只能有一个半开探测。`health.operationalPolicy` 只能出现阈值与计数。核对进程审计中有首次拒绝、`command_circuit_opened` 和恢复后的 `command_circuit_closed`,关联 ID/ERP 作用域正确且没有 payload;同一拒绝一分钟内重复 100 次不得增加 100 行。任一运维环境变量含空项、重复命令、非法整数或越界值时,ERP 可以继续启动,但桥不得发布发现文件。
|
||||
- 验证每个并行 ERP 启动实例只创建自己的 `%LOCALAPPDATA%\Langsu\Lserp\AgentBridge\Audit\audit-p<PID>-s<UTC ticks>.jsonl`,没有共享追加或覆盖。模拟只读目录、非换行截断尾部、64 MB 单文件门限、512 MB/256 文件待归档门限时,桥必须在发布发现文件或生成新计划前失败关闭;归档只能移动已经退出的实例日志,禁止自动删旧日志来绕过门限。
|
||||
- AstrBot Key 只授予 `chat + file` scopes,存放在 Windows 凭据管理器;MiniMax Key 只由 AstrBot 服务账号的秘密管理器注入。
|
||||
- 已在聊天、日志、脚本或工单中暴露过的 Key 一律先撤销再创建新 Key。
|
||||
- 以 `user_id=1` 且名称精确为“管理员”的内置账号验证诊断能力;另外创建或选择一个名称同为“管理员”但 ID 不为 1 的隔离测试账号,确认其 `context.isAdministrator=false`,且 `modules diagnose`、初始化追踪和无菜单权限读取均返回 `administrator_required`/权限拒绝。不得通过复制显示名获得 Agent 管理权限。
|
||||
- 客户机 WebView2 Evergreen Runtime 必须为 `151.0.4129.50` 或更高版本,与宿主锁定的 Release SDK `1.0.4129.50` 满足完整 API 兼容要求;组策略暂停自动更新、注册表版本无法解析或版本过低时,商用预检必须返回 `webview2_version_unsupported`。
|
||||
- 在 Windows 10/11 实机分别以 100%、125%、150% 和 200% 缩放,并覆盖主副屏不同 DPI,验证透明背景、拖动把手、标题栏拖动、业务按钮点击和退出;不得出现黑色矩形、点击区域变成拖动区、窗口移屏后裁切或退出后残留桌宠/WebView2 进程。该项只能由实机验收记录证明,跨平台编译不能替代。
|
||||
- 鼠标在宠物主体上连续悬停约 480 ms 后必须展开对话面板,悬停展开不得抢走 ERP 当前输入控件的键盘焦点;悬停展开后的第一次点击必须保持面板展开并聚焦聊天输入框,再次点击才可收起。先在 ERP 切换两个不同模块,每次展开后标题必须刷新为当前活动模块,不能沿用上次缓存;只读上下文刷新失败时不得伪造模块信息或阻断后续聊天。该项必须在 Windows 实机用可输入控件和两个有权菜单留存验收证据。
|
||||
- 桌宠标题下方必须显示当前受信任 ERP 会话范围的账套、子系统、用户和数据库指纹前 12 位证据,不得显示服务器、库名、连接串或完整数据库指纹。先生成一条可执行计划,再分别切换数据库、ERP 用户、账套和子系统;每种情况下点击执行都必须在向 ERP 发送执行请求前重新验证范围、作废旧计划并提示重新生成预览,且数据库调用、确认框和完成审计均为零。恢复原范围后也必须重新规划,不能复用旧计划;聊天与只读重新发现能力仍应可用。
|
||||
- 桌宠必须提供“录入采购发票”“申请请假”“当前界面能做什么”和“只读诊断当前界面”固定快捷入口。没有附件或完整采购明细时,采购入口只能提示补充材料,不得产生写计划;请假入口必须询问用户原始日期表达与时段、类型、原因和是否提交审批,不得自行换算日期或补全提交意图。“检查业务能力”只在受信任上下文确认当前身份为 ERP 内置管理员时显示;普通用户和同名非内置管理员均不得看到。即使按钮可见,直接调用 `adapters.status` 仍必须由 ERP 服务端权限复核,且结果只能包含稳定阻断代码、受控说明和下一步,不得泄露配置证据、SQL 或物理字段。
|
||||
|
||||
## 2. 低代码元数据取证
|
||||
|
||||
旧 ERP 正常登录会调用 `P_Login_pr`,并可能登记在线会话、IP/MAC 或补老库兼容字段,所以“业务查询只读”不等于“整个连接过程零写入”。若客户只批准对目标库做零写入摸底,禁止先运行直连 `lserp-cli --ledger/--user`。应由 DBA 创建没有数据库/服务器角色、没有数据库级 DML/DDL/EXECUTE、没有任何对象或 Schema 写/执行权的专用 SQL 账号,并在 Windows PowerShell 5.1 运行:
|
||||
|
||||
```powershell
|
||||
$credential = Get-Credential -UserName <客户SELECT-only SQL账号>
|
||||
.\Invoke-LserpSelectOnlyCatalogSnapshot.ps1 `
|
||||
-Server <证书名称匹配的SQL Server TLS端点> `
|
||||
-Database <目标数据库> `
|
||||
-Credential $credential `
|
||||
-OutputPath D:\Acceptance\Evidence\select-only-catalog-new.json
|
||||
```
|
||||
|
||||
脚本固定使用系统信任链校验的 TLS、`ApplicationIntent=ReadOnly`、关闭连接池和持久凭据;不提供跳过证书验证的参数。它用同一固定 SELECT 在目录读取前后各检查一次 `sysadmin/db_owner/db_ddladmin/db_datawriter`、服务器与数据库权限、每张用户表/视图、过程/函数和 Schema 的有效写/执行权,任一次非零即返回 `database_principal_not_select_only` 且不发布文件。两段嵌入查询已由锁定的 ScriptDom `TSql100Parser` 证明全部 AST 顶层语句都是 `SELECT`;只读 `sys.*`,不执行过程、不读取业务行。采集开始和结束还会把磁盘脚本 SHA-256 与 PowerShell 实际加载的完整 AST 文本 SHA-256 比较,运行中替换脚本返回 `tool_source_changed`。新建 ACL 文件只保留服务器、库、主体和目录成员的 SHA-256、兼容级别与对象计数,不保留原始名称或口令。该快照只能证明 SQL 目录摸底没有使用可写主体,不能证明 ERP 用户权限、菜单、账套、子系统或业务过程正确。
|
||||
|
||||
采集后立即用最终 CLI 离线运行 `lserp-cli adapters verify-catalog-snapshot --input <snapshot.json> --profile <客户画像.json> --tool-sha256 <随包采集脚本SHA-256>`。验证器只接受 `schemaVersion=1.1`,拒绝未知/重复字段、链接路径、超过 16 MB、过期/未来时间、脚本哈希不符、缺少权限前后双检、脚本字节不稳定、权限/TLS/安全声明不符、目录哈希重复/乱序或集合摘要篡改,并把画像关键目录按同一小写不变区域规范转换为哈希比对。旧 1.0 快照必须重新采集。输出不得出现原始服务器、库、主体、表、列或过程名;即使全部匹配也固定 `registrationReady=false`。
|
||||
|
||||
正式现场推荐用随包包装器一次完成采集与离线验证,避免实施人员手工复制错误哈希或误用另一份 CLI。所有哈希均从已审查发布记录取得,SQL 口令仍只存在于交互式 `PSCredential`:
|
||||
|
||||
```powershell
|
||||
$credential = Get-Credential -UserName <客户SELECT-only SQL账号>
|
||||
.\Invoke-LserpSelectOnlyProfilePreflight.ps1 `
|
||||
-Server <证书名称匹配的SQL Server TLS端点> `
|
||||
-Database <目标数据库> `
|
||||
-Credential $credential `
|
||||
-ProfilePath .\customer-profile.json `
|
||||
-ExpectedProfileSha256 <画像原始文件SHA256> `
|
||||
-CliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-ExpectedCliSha256 <最终签名CLI的SHA256> `
|
||||
-ExpectedSignerThumbprint <CLI签发证书40位指纹> `
|
||||
-ExpectedCollectorSha256 <随包采集脚本SHA256> `
|
||||
-SnapshotOutputPath D:\Acceptance\Evidence\select-only-catalog-new.json `
|
||||
-ReportOutputPath D:\Acceptance\Evidence\select-only-profile-preflight-new.json
|
||||
```
|
||||
|
||||
包装器会锁定画像、采集器和 CLI,复核 Authenticode,且报告只保留制品哈希、数据库作用域哈希、匹配布尔值和缺失目录成员哈希。目录漂移时报告为 `passed=false` 并返回非零;工具、签名、协议或安全证明异常时不会保留未验证快照。它不运行旧 ERP 登录、不执行存储过程、不读取业务行,也不会把通过结果解释为可注册写命令。
|
||||
|
||||
完成客户批准的正常 ERP 登录后,分别在采购、请假和诊断所属子系统执行只读业务检查。采购与请假使用实际获权业务用户,诊断使用管理员;即使 ERP 可以在同一进程切换子系统,也必须为每个作用域重新采集独立审批、预检和交接文件,禁止把切换前后的上下文拼成一份证据:
|
||||
|
||||
先在客户机本地由实施人员从批准的 ERP 启动记录/只读会话交接中取得本次 PID、64 位 `databaseScopeFingerprint`、用户编号、用户名、账套、子系统和管理员属性,并由人工确认它们属于本次批准范围;不得猜测、从模型补全或把原始上下文发送给 AstrBot/MiniMax、聊天或普通日志。桥/工作流命令(包括独立签名管理员验收工具 `lserp-cli.exe`)不接受只传 PID 的引导调用;需要本地复核时,必须完整传入 `--erp-process-id` 加六个 `--expected-*` 范围参数,例如 `lserp-cli.exe bridge context --erp-process-id <PID> --expected-database-scope-fingerprint <64位小写SHA-256> --expected-user-id <用户编号> --expected-user-name <用户名> --expected-account-book <账套> --expected-subsystem-id <子系统编号> --expected-is-administrator <true|false>`。该调用只验证已批准的预期范围与当前 ERP 上下文逐项一致,不能作为“先连接后猜测范围”的来源。受限 `lserp-agent-cli.exe` 第一次调用也必须携带同一完整范围。正式现场应把 `field-readonly-validation.example.json` 复制到包外受控目录,填写并独立批准上述预期范围、CLI/ERP/预检脚本哈希、发布策略和 `purchase/leave/diagnosis/support` 到客户实际模块编号的映射,再用输入文件 SHA-256 调用 `Invoke-LserpFieldReadOnlyValidation.ps1`。先带 `-ValidateInputOnly` 生成不执行预检的脱敏交接报告,再换新输出文件去掉该开关;包装器不会自动采信 Bridge 自报范围,会把审核值完整传给下列底层只读预检及每个受限 CLI 子进程。审批最长 24 小时,模板默认拒绝,且不允许任何数据库凭据字段。不同 ERP 身份应各自生成审批文件和预检证据。
|
||||
|
||||
以下直接调用只作为包装器所绑定的底层参数对照;不得用手工命令替代已批准输入的哈希交接:
|
||||
|
||||
```powershell
|
||||
$cli = 'D:\ApprovedPackages\Lserp-AgentPet-@LSERP_PACKAGE_VERSION@-win-x64\Host\lserp-agent-cli.exe'
|
||||
$erp = 'D:\Acceptance\legacy-erp-build\Runtime\Ls_ERP.exe'
|
||||
$rollout = 'C:\ProgramData\Langsu\AgentBridge\command-rollout.json'
|
||||
.\Invoke-LserpReadOnlySessionPreflight.ps1 `
|
||||
-CliPath $cli `
|
||||
-ExpectedCliVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-ExpectedCliSha256 (Get-FileHash -LiteralPath $cli -Algorithm SHA256).Hash `
|
||||
-ExpectedSignerThumbprint <40位客户发布证书指纹> `
|
||||
-ErpProcessId <当前ERP进程PID> `
|
||||
-ExpectedErpSha256 (Get-FileHash -LiteralPath $erp -Algorithm SHA256).Hash `
|
||||
-ExpectedDatabaseScopeFingerprint <已人工核准的64位数据库作用域指纹> `
|
||||
-ExpectedUserId <当前ERP登录用户ID> `
|
||||
-ExpectedUserName <当前ERP登录用户名> `
|
||||
-ExpectedAccountBook <当前ERP账套> `
|
||||
-ExpectedSubSystemId <当前ERP子系统ID> `
|
||||
-ExpectedIsAdministrator $false `
|
||||
-ExpectedRolloutCustomerId <客户部署标识> `
|
||||
-ExpectedRolloutPolicySha256 (Get-FileHash -LiteralPath $rollout -Algorithm SHA256).Hash `
|
||||
-ModuleCodes @(<采购模块或导航编号>) `
|
||||
-RequirePurchaseWorkflow `
|
||||
-OutputPath D:\Acceptance\Evidence\purchase-readonly-session-preflight-new.json
|
||||
```
|
||||
|
||||
请假会话只传请假模块并只打开 `-RequireLeaveWorkflow`;诊断会话传入四类故障证据涉及的全部去重模块,只打开 `-RequireDiagnosisWorkflow`,且 `-ExpectedIsAdministrator` 必须为 `$true`。三份报告可以记录相同或不同 PID,但用户、子系统、模块和时间戳必须各自匹配,能力表中非当前角色命令只能作为非必需能力出现。
|
||||
|
||||
脚本只接受 Windows PowerShell 5.1、最终签名且未变化的受限 `lserp-agent-cli.exe` 和 ERP 文件句柄;它会先实际运行 `version`,逐字段确认版本、`bridgeOnly=true`、`databaseDirectAccess=false` 和 `sessionSource=current_logged_in_erp_process`,因此管理员 `lserp-cli.exe` 不能冒充现场运行时。它要求指定 PID 的主模块名精确为 `Ls_ERP.exe`,且 ERP 哈希和签发者与最终构建证据一致,前后还会复核 PID 启动时间、路径及文件哈希未变。`-ExpectedUserId`、`-ExpectedUserName`、`-ExpectedAccountBook`、`-ExpectedSubSystemId`、`-ExpectedIsAdministrator` 都是必传值,第一次读取 `bridge context` 时即与当前 ERP 会话逐字匹配;任一不符都会在能力和动态模块读取之前以 `expected_session_scope_mismatch` 失败,诊断工作流还会拒绝非管理员预期值。所有子进程参数在运行时再次经过精确只读白名单,只能调用本地 `version`、`bridge health/context/capabilities` 与风险固定为 `read`、`executionAllowed=false` 的 `bridge plan module.parameters`。它不接收数据库地址、库名、账号口令或连接串,不建立数据库连接,不调用 `bridge execute`,也不读取业务行。`schemaVersion=1.5` 报告使用 `CreateNew`,必须包含 `cli_runtime_identity`、`rollout_database_scope`、`expected_session_scope` 与 `dynamic_module_execution_contracts` 检查;除 ERP/运行时 CLI 版本、哈希与签发者、会话标识的域分离 SHA-256、数据库作用域指纹和能力版本/风险外,只保留模块合同/配置指纹、scalar/单值 Lookup/unsupported 计数、严格载荷策略与可编辑/必填参数数量、经审核的原生保存族/指纹,以及新增/修改的就绪或阻断状态。载荷参数 ID 必须与当前合同中的可编辑/必填集合逐项一致,基础档案与单据的明细行约束必须匹配,单据通用修改必须保持关闭;字段标签、业务值、连接信息、物理字段和临时输入不会保留。旧 1.4 及更早报告不能进入当前总验收。客户适配器尚未注册的第一次动态参数发现可以省略对应 `-Require*Workflow` 开关,但这种报告不证明该工作流可用;完成适配器、签名证据和默认拒绝发布策略后,各会话必须用新文件及自身唯一的工作流开关再次通过。该报告始终固定 `productionWriteAuthorized=false`,不能代替后续事务 UAT。
|
||||
|
||||
```text
|
||||
lserp-cli adapters inspect purchase <客户采购发票模块编号>
|
||||
lserp-cli adapters inspect leave <客户请假模块编号>
|
||||
lserp-cli adapters export-review purchase <客户采购发票模块编号> --output <purchase-review.json>
|
||||
lserp-cli adapters export-review leave <客户请假模块编号> --output <leave-review.json>
|
||||
lserp-cli adapters verify-review --input <purchase-review.json>
|
||||
lserp-cli adapters verify-review --input <leave-review.json>
|
||||
lserp-cli adapters validate-fields purchase --input <purchase-mapping.json>
|
||||
lserp-cli adapters validate-fields leave --input <leave-mapping.json>
|
||||
```
|
||||
|
||||
人工审核导出的模块名、模块类型、主/明细表与每个候选字段,并校验复核包 `contentSha256`。复核包绑定当前账套、子系统和管理员用户,且 `registrationReady` 固定为 `false`。候选评分和内容哈希仅是证据,不是自动绑定;只有名称相似但业务含义未证实时必须停止。客户 1.2 画像中本工作流每个阻断项只有在对应证据真实完成并签字后才能从 `open/resolution=null` 改为 `resolved`;此时必须填写代码指定的 `evidenceArtifact`、最终制品原始字节 SHA-256、批准人和 UTC 批准时间。固定阻断码不能删除、替换或追加;签发脚本和 ERP 运行时都会要求本工作流 `approved=true/openBlockerCount=0`,并把每个解决哈希与签名清单中即将使用的字段映射或写集成文件逐字节复核。采购还要同步批准目标模块选择。采购与请假可独立验收,不能为了启用其中一条而伪造另一条状态。
|
||||
|
||||
对普通用户有权限的基础档案和单据模块分别执行 `lserp-cli modules parameters <moduleCode> --ledger <账套> --user <用户> --subsystem <子系统>`,并通过同一已登录 ERP PID 计划 `module.parameters` `1.1`。两路结果必须来自当前数据库低代码配置,包含全部可见主表/明细参数的不透明 `parameterId`、类型、格式、必填、只读、默认值、Lookup、`inputMode`、`requiresDedicatedAdapter`、`maximumEncodedBytes`(`LimitLen`,ERP 进程默认编码字节数)和数值 `maximumDecimalPlaces`(`DataDec`,缺省 2)标记,但不得出现表、SQL、物理字段、Lookup 键/显示列或连接信息。普通标量必须是 `scalar`,所有单值选择控件即使配置数据源为空也必须是 `lookup-single` 并停止于唯一解析门禁;多选、设备、动态 SQL、计算、回填多行、单据来源以及未识别类型必须是 `unsupported`、不可编辑且要求专用适配器。复选框合同只接受小写 `true/false`,受信任写 XML 中必须是旧 ERP 的 `1/0`。换用户、账套或子系统后 `contractFingerprint` 必须变化;仅改变数据库返回字段行顺序不得变化;修改任一控件配置、长度/小数位、可见性、必填、默认值、Lookup、主/明细定义或 SQL 钩子后旧合同必须失效并要求重新发现。再用合同按 `parameter_entries_v1` 计划 `module.record.prepare-create`:正常纯标量主表/明细输入只能返回 `draft` 参数预演,跨范围/漂移、未知、重复、只读、密码/图片、复杂或未知控件、错误布尔/整数/数值/日期、超 `LimitLen`、超 `DataDec`、非法约束配置、缺少必填和未解析 Lookup 分别必须失败关闭,响应不得出现物理字段或 SQL。通用事务适配器尚未验收时必须明确返回 `genericWriteExecutionAvailable=false`,该计划不能被保存或执行,不得弹 ERP 原生确认、占用幂等键或调用数据库写入。
|
||||
|
||||
现场所说的“传递全部参数”必须分成两层验收:`payloadContract.masterParameterIds/detailParameterIds` 必须逐项覆盖当前合同的全部可编辑参数,`required*ParameterIds` 必须精确等于其中无 ERP 默认值的必填子集;具体业务载荷必须携带这个必填子集,可显式携带其他可编辑值。含当前低代码默认值的参数允许从业务载荷省略,让服务器默认逻辑生效;不得为追求“数量齐全”而传空串覆盖默认值,也不得把只读、系统编号或专用适配器字段加入模型载荷。固定事务过程必须使用当前配置指纹二次验证这两个集合和服务器默认行为。
|
||||
|
||||
完成动态新增/修改适配器验收后,分别通过 `module.record.resolve-create → module.record.create` 和 `module.record.resolve-update → module.record.update` 取得可信写预览。桌宠必须把新增主表字段、每行明细及修改前/后值结构化展示,并在用户滚动核对全部内容前禁用执行按钮;任何未知键、物理字段名、重复不透明参数 ID、明细行号不连续、控制字符、越界值、顶层与 `data.preview`/`parameterPreview` 不一致、合同或适配器摘要无效都必须禁止执行。使用空字符串、多行文本、512 个主表字段、1000 行明细、5000 个总值和 512 个修改字段验证允许边界,再各增加一项验证失败关闭;确认后修改权限、配置、Lookup、记录快照或验收证据时不得沿用旧计划。
|
||||
|
||||
另选取模块返回控件做模式验收:`116/117/160/161` 在 `IsRadio=1` 时必须是 `lookup-single`,在缺列、空值或 `0` 时必须是 `unsupported`;`42/171/172` 必须保持显式单选,`43` 与扩展多字段回填 `173/174` 必须要求专用适配器。只改变 `IsRadio` 后旧 `contractFingerprint` 必须失效,旧 Lookup 凭证和计划均不得复用。
|
||||
|
||||
选取半天日期控件 `445`:公开合同只接受 `yyyy-MM-dd|am-or-pm`,预演必须拒绝 `yyyy-MM-dd 上午/下午`;进入受信任事务适配器的值必须反向转换为旧控件真实格式。上午、下午各验证一次,并确认合同预览仍显示用户可理解的公开值而不暴露物理字段。
|
||||
|
||||
禁止人工直接改写上述状态。使用同一客户 ERP 的内置管理员执行 `lserp-cli adapters prepare-profile-activation <purchase|leave>`,同时提供最终字段映射、已通过的只读契约证据、完整写集成报告、运行配置哈希、源码提交和商用包哈希;CLI 通过在线字段与关键目录复核后只新建未签名候选。候选仍固定不可注册,必须继续完成工作流签名清单、V2 验收证据写入和 V3 运行时就绪核对。
|
||||
|
||||
在进入上述在线步骤前,先离线执行 `lserp-cli adapters activation-checklist --input <客户画像.json>`。它必须用非零退出码逐项列出尚未完成的稳定阻断码、所需证据类型和固定下一步,不得输出数据库名、物理字段、画像证据正文或 SQL;即使全部阻断项关闭并返回 0,输出仍必须保持 `activationAllowed=false/registrationReady=false`,不得被部署脚本解释为已经可以写库。
|
||||
|
||||
桌宠失败恢复也必须作为客户 UAT 门禁,不得只验证成功路径:
|
||||
|
||||
- 采购预览生成后,由验收人员在受控 UAT 数据中制造同供应商同发票号,执行必须返回 `duplicate_invoice`、`action=inspect_existing_record`、`retryable=false/planInvalidated=true`;桌宠显示固定下一步、错误码和关联 ID,同一计划再次请求必须在确认框和数据库调用前被拒绝。
|
||||
- 分别在确认后改变采购开放来源、请假日历/冲突/假别或流转类别配置,必须返回稳定变化码与 `action=replan`,旧计划立即撤销;客户请假过程即使返回包含内部 SQL/对象名的自由 `reason`,计划警告和执行错误也只能使用固定公开文案,不得把原始 SQL Server 消息、对象名、物理字段或业务值带到 Host、桌宠、AstrBot 或 MiniMax。
|
||||
- 在 ERP 原生确认框点击取消,必须返回 `user_cancelled` 与 `action=review_and_retry`,证明没有写入且原预览仍可再次核对;模拟桥超时或固定 `workflow_database_error` 时必须返回 `action=reconcile_execution` 并复用原宿主幂等键,先核对 ERP 窗口、业务记录和审计,不能盲目生成第二张单据。
|
||||
- 向测试 Host 注入额外 `sql/physicalField`、未知 `action=run_sql`、字符串布尔值或控制字符,命名管道验证、Host 白名单投影或页面协议校验必须失败关闭;普通用户最终只能看到固定恢复说明和关联 ID。
|
||||
|
||||
## 3. 客户存储过程实现
|
||||
|
||||
以 `SqlServer/002_workflow_adapter_contract.sql` 为接口契约,对当前客户实现白名单动作,并部署只读的 `SqlServer/006_workflow_readiness_v3.sql` 作为运行时门禁。不得改为动态 SQL、任意存储过程名或模型可控表名。实施前必须读取当前业务数据库的 `sys.databases.compatibility_level`:级别 `130` 及以上实现 JSON 参数过程 `p_lserp_agent_workflow_read`;低于 `130` 时使用 `p_lserp_agent_workflow_read_compat100`,JSON 只在受信任 ERP 进程中按固定动作解析,并仅传入契约声明的标量参数。兼容采购写只允许精确过程 `p_lserp_agent_workflow_write_purchase_compat100` 接收固定标量和受信任 ERP `XmlWriter` 生成的限量行集;兼容请假写只允许精确过程 `p_lserp_agent_workflow_write_leave_compat100` 接收固定强类型标量。两条路径均须完成独立 DBA 评审、NOEXEC 编译、目标菜单编辑权限与业务行范围复核、回滚/持久幂等/审计集成验收和 TrustedPeople 签名;任一条件缺失时不得注册,也不得退化为动态 SQL 或任意过程调用。V3 门禁必须逐项核对实际选用过程的有序参数签名和修改时间;签名漂移或验收后改过程时必须失败关闭并重新验收。
|
||||
|
||||
每个写动作必须在同一个 `SERIALIZABLE` 事务中同时完成:
|
||||
|
||||
1. 根据当前账套、子系统和用户再次校验权限。
|
||||
2. 重新读取来源单/日历/冲突/审批状态,比对预览指纹。
|
||||
3. 写入业务主表、明细、关联和工作流记录。
|
||||
4. 写入 `p_agent_command_idempotency`、`p_agent_business_audit` 和必要的 `p_agent_integration_outbox` 记录。
|
||||
5. 返回契约规定的 `success/code/message/record_id`、实际幂等键与输入指纹、事务证据 ID 和业务审计 ID;任何证据缺失都回滚。
|
||||
|
||||
只读过程完成后,复制并填写 `Lskj.Cli/adapter-examples` 中的两份契约探针。只允许使用验收库中的脱敏测试数据,采购探针发票号必须尚不存在,请假探针记录必须是当前用户可提交的测试草稿:
|
||||
|
||||
```text
|
||||
lserp-cli adapters verify-contract purchase --input <purchase-probe.json> --output <new-purchase-read-evidence.json> --user <管理员>
|
||||
lserp-cli adapters verify-contract leave --input <leave-probe.json> --output <new-leave-read-evidence.json> --user <管理员>
|
||||
lserp-cli adapters verify-contract-evidence --input <new-purchase-read-evidence.json>
|
||||
lserp-cli adapters verify-contract-evidence --input <new-leave-read-evidence.json>
|
||||
```
|
||||
|
||||
在线探针只调用固定白名单 read 动作;失败时仍新建证据文件并返回退出码 6。证据绑定账套、子系统、管理员、模块、探针输入 SHA-256 和检查结果,但不保存发票号、员工号、物料、原因或记录号。哈希完整性不能证明客户过程内部没有动态 SQL,参数化查询仍须人工代码审查;证据中的 `registrationReady` 固定为 `false`。
|
||||
|
||||
## 4. 采购附件闭环
|
||||
|
||||
至少使用一张真实脱敏发票图片、一份 PDF 和一份 XLSX/CSV 明细测试;每次只允许 1–3 份附件,单份源文件不得超过 12 MB,宿主总量不得超过“数量 × 单文件上限”(默认 36 MB),Schema、共享内核、兼容网关、验收投影和客户存储过程必须使用同一单文件上限:
|
||||
|
||||
- 固定调用链为 `purchase.invoice.resolve -> purchase.invoice.create`。第一步只把供应商名称/税号、币种、物料名称/规格/单位解析成 ERP 编码,不能落库;OCR 中看似存在的编码也必须经过 ERP 只读过程复核。解析结果包含绑定当前用户、账套、子系统和完整创建输入的 5 分钟 HMAC `resolutionProof`,第二步必须原样携带;改动任一编码、跨会话复用或过期时,必须在查询业务表前拒绝。第二步才查询开放来源、做确定性匹配并生成写入预览。
|
||||
- `purchase.resolve_supplier/resolve_currency/resolve_material` 必须只按精确编码、税号、全称或人工维护别名匹配当前账套有效主数据;零结果或多结果时返回候选并停止,严禁 `TOP 1`、相似度最高自动选择或模型自行补编码。
|
||||
- 启用采购适配器时 `lineAmountMode` 必须明确为不含税或含税,不能使用 `None/0`。输入必须包含发票头不含税金额、税额、价税合计和每行税额;CommandKernel 按 `currencyScale`、四舍五入与行/头容差复核“数量×单价、行金额×税率、明细汇总、头部价税恒等式”,任一不平都不得产生可执行计划。共享内核还强制数量容差不超过 `0.01`、单价绝对/相对容差不超过 `1/1%`、税率容差不超过 `0.001`、行/头金额容差不超过 `1/5`;运行配置和验收探针都不能绕过。客户写过程在事务内还必须独立重算一次。
|
||||
- 发票日期只接受严格 `YYYY-MM-DD`(例如 `2026-08-11`),不接受任何时间或时区。分别用 `T00:00:00`、`Z`、`+08:00`、不存在日期和区域格式日期验证 Schema 在主数据查询前返回 `input_schema_violation`;直接调用处理器的 UTC/Local/非零时间 `DateTime` 也必须返回 `purchase_invoice_date_invalid`。最终发布策略必须把 `purchase.invoice.resolve/create` 都锁定为 `commandVersion=1.4`;保留 `1.2/1.3` 必须因合同漂移被拒绝。
|
||||
- 开放来源必须返回稳定采购单标识、人工单号、采购明细标识、供应商、币种、物料、单位、剩余数量、原币单价、税率和正汇率。单位不同不得匹配;一张发票命中不同汇率必须拆单或由财务重新确认。剩余数量要扣除全部未删除、未取消、未作废的既有单据占用,查询超过 10000 行时失败关闭,不能截断后继续匹配。
|
||||
- 准备同一供应商、币种下分别属于两个“组织 + 部门 + 采购员”元组的有效采购来源 A/B,只给当前测试用户签署 A 的 `p_agent_purchase_row_scope`。通过桌宠/AstrBot 执行 `purchase.invoice.resolve` 并进入来源匹配时,桥响应、模型上下文和日志只能出现 A,B 的单号与明细标识均不得出现;删除、过期或破坏 A 的审批哈希后必须返回 `purchase_row_scope_denied` 且不查询出任何来源。再以精确内置管理员账号复测,结果仍不得绕过行级范围。
|
||||
- 在 `acc_1007` 中分别准备主表 `visible=0/1`、明细 `isVisible=0/1` 和字段权限导致有效宽度为 0 的测试字段;`adapters inspect/validate-fields` 必须只把主表 `visible=1`、明细 `isVisible=0` 且有效宽度大于 0 的字段视为可映射。任何隐藏物理列必须返回 `mapped_field_not_exposed` 或不进入候选,不能因管理员登录而绕过。
|
||||
- 上传目录先完成杀毒/隔离;AstrBot 以专用低权限 Windows 服务账号运行。电子 PDF 必须使用随包 PDFium 最多三页隔离渲染、逐页 MiniMax 严格识别和跨页合并,生成的 `pdfium_minimax_pages_v1` 必须与源文件摘要、每页 PNG 摘要及精确提取摘要一起进入 `resolutionProof`、输入指纹、XML v3 写载荷和 `p_agent_business_source_document.preprocess_contract`。图片、CSV、XLSX 分别只能使用 `minimax_vlm_0.0.4`、`document_sandbox_csv_v1`、`document_sandbox_xlsx_v1`;后缀与合同不匹配必须在业务查询前拒绝。验证文档解析 worker 继承不到 MiniMax Key 和代理凭据,不能联网、写文件或再启动子进程,CPU、内存、超时、输出和进程数限制均生效。
|
||||
- 使用超大、加密、截断、畸形、宏、外链和公式样本测试;worker 必须 fail-closed,超时或超限后整个进程树被回收,错误只返回稳定分类,不返回路径、堆栈或文档内容。另以受控测试工具在来源摘要生成后替换图片或文档(含替换后再恢复),视觉网络请求或文档解析必须以 `attachment_changed_during_preprocess` 停止,不能生成 ERP 计划;正常样本返回的来源摘要必须等于实际发送/解析字节。
|
||||
- 唯一匹配:供应商、币种、物料、单位、数量、原币含/不含税单价、税率、汇率、行金额和来源行全部命中,生成可确认预览。
|
||||
- 确认前必须直接核对桌宠中的可信服务端预览,而不是只看模型回复:16 个发票汇总字段必须完整显示,随后逐行列出发票行号、物料、本次数量/单位/单价/税率/税额/行金额,以及匹配到的采购单号、来源明细、来源剩余数量/单位/单价/税率/汇率。使用 1 行、3 行和 200 行边界样本确认预览区可滚动且最终确认按钮始终可达;删除任一逐行字段、把 `candidateCount` 改为 2、加入问题项、让兼容别名与来源事实不一致或让本次数量超过来源剩余数量时,宿主和桌宠都必须以 `plan_invalid`/“逐行匹配证据不完整”失败关闭,不得出现可执行按钮或 ERP 原生确认框。
|
||||
- 歧义匹配:至少两个来源行同样匹配时,Agent 只显示候选和差异,不生成可执行计划。
|
||||
- 澄清续接:上传附件后分别制造供应商、币种、物料或来源行多候选,第一轮必须保留附件;用户在下一轮补充唯一选择时不得要求重新上传。最终 `purchase.invoice.create` 计划中的 `sourceDocumentCount` 必须等于待处理附件数,`sourceDocumentSetSha256` 必须按本文件第 8 节/`WRITE_ACCEPTANCE.md` 的固定算法与本地原文件集合一致。删除字段、修改数量或摘要时,宿主必须返回 `attachment_plan_binding_invalid`、移除可信计划、保留附件且不出现确认按钮;请假、诊断或普通回复也不得顺带清空采购附件。
|
||||
- 超额与变化:一张发票多行聚合超出剩余数量,或预览后来源行的内部标识、人工单号、单位、剩余数量、单价、税率、汇率被他人修改,必须返回 `purchase_source_changed` 并要求重新预览;人工单号变化即使内部主键未变也不得沿用旧确认。
|
||||
- 幂等重放:同一 `idempotencyKey + 输入指纹` 返回原结果,不新增第二张单;同 Key 不同输入直接拒绝。
|
||||
- 确认边界:模型文本中的“已确认”无效,必须先点桌宠按钮,再点 ERP 原生确认窗口。
|
||||
- 关联链:resolve 与固定 create 续接、最终预览、桌宠执行和 ERP 计划/完成审计必须使用同一个 `bridgeCorrelationId`;修改页面执行请求的关联 ID 必须在进入命名管道前返回 `plan_correlation_mismatch`。再用协议测试工具保留同一 `clientSessionId` 和 `planId`、只调换关联 ID,ERP 进程内桥也必须在弹出原生确认窗口及占用幂等键前返回同一码;两种负例的业务表和审计表均不得新增记录。
|
||||
|
||||
## 5. 请假闭环
|
||||
|
||||
- 固定测试链为 `hr.leave.resolve -> hr.leave.create -> hr.leave.submit(可选)`。解析命令只返回 `resolvedCommand/resolvedInput`,不能落库;`resolvedInput` 必须包含绑定当前用户、账套、子系统、员工、假别、流程、本地时段、日历工时、原因和提交意图的 5 分钟 HMAC `resolutionProof`。创建命令必须原样复核;绕过解析直接拼输入、修改任一字段、跨会话复用或过期时,应在读取请假业务数据前返回 `leave_resolution_invalid`/`leave_resolution_proof_expired`。创建与提交各自重新预览和确认。
|
||||
- 最终 `hr.leave.create` 确认预览必须完整且只包含员工、请假类型、流转类别、无时区开始/结束时间、核算工时、原因和固定为 `false` 的“创建后提交”八项;`hr.leave.submit` 的第二次预览必须完整且只包含申请编号与固定动作“提交审批”。桌宠顶层预览必须与服务端 `data.preview` 逐值一致。分别删除原因、替换起止时间为带 `Z/+08:00` 的值、把“创建后提交”改为 `true`、增加未知字段或把提交动作改为其他文字,宿主和桌宠都必须返回 `plan_invalid`/“请假确认信息不完整”,不得出现可执行按钮或 ERP 原生确认框。
|
||||
- 用“我明天下午请事假,原因是去医院”验证:`employeeId` 来自当前 ERP 会话,`leaveTypeCode` 来自 `leave.resolve_type` 的唯一结果,开始/结束来自 `leave.resolve_calendar_range`,预览必须显示客户时区和员工日历工时。再用测试日之后的“本周五下午”或“下周一上午”以及未写年份的“M月D号下午”验证 `hr.leave.resolve` `1.4`:星期按周一为一周开始,月日取 ERP 本地时间下尚未过去的最近一次,跨年时进入下一年;AstrBot 必须原样传递表达,不能使用模型时钟换算。`flowTypeCode` 必须来自 `leave.resolve_flow_type` 的当前有效配置行 id;不唯一时桌宠必须展示候选并追问。
|
||||
- 用一个已知员工排班总工时的绝对日期表达(例如“我从 2026-08-12 下午到 2026-08-14 上午请事假,共 11 小时”)验证多日区间:两端分别调用员工日历边界,测试输入必须把已知总工时作为一致性断言;完整区间总工时必须由 `leave.calculate_hours` 重新核算并与断言一致,再用于流程类别解析、短期凭证、创建输入和二次预览。任一端没有明确上午/下午/全天、区间反向、跨度超过 `maximumCalendarDays`、两端时区不一致、工时不符或日历总工时为零时必须阻断;周末或非工作日不能由模型自行扣减工时。
|
||||
- `hr_4011` 当前天数/岗位联动仍引用已失效的流程 id 3195-3200,而有效审批步骤使用 3629-3634。修复并签署该配置前,禁止按岗位名称或“五天内/以上”文案自动选路;验收还要确认创建记录已派生请假人姓名、部门、岗位和天数,否则后续审批人解析可能错误。
|
||||
- `leave.resolve_type` 必须只查询当前员工可用的已启用假别和人工维护别名;零结果或多结果时返回稳定问题代码和候选项,不生成写入计划。
|
||||
- “明天”但未说明上午/下午/全天、裸“周一/星期一”、已经过去的“本周X”、不存在的“2月30日”、超出未来 366 天、客户日历无可申请时段,都必须停止;不得由模型补范围、日期或时间。
|
||||
- `allowPastStart=false` 时,用已过当天时段(例如下午再申请“今天上午”)验证解析阶段返回 `leave_start_in_past`,且不产生 `resolvedInput`。明确传入与员工日历不一致的 `requestedHours` 时应返回 `leave_requested_hours_mismatch`;未明确说出工时时 AstrBot 必须省略该值。请假开始/结束和日历解析结果只接受 `DateTimeKind.Unspecified` 的无时区本地时间;直接注入机器 `Local` 或 UTC 时间必须在生成解析凭证和业务查询前拒绝。
|
||||
- 当前用户无代申请权限时,`employeeId` 只能是当前员工。
|
||||
- 停用假别、非工作时间、超最大天数、工时不符、时间冲突和预览后规则变化都必须阻断。自然语言没有手填工时时,`requestedHours` 必须取 ERP 日历核算值并进入解析凭证与输入指纹;原生确认后即使排班只变化 `0.01` 小时、仍位于普通工时容差内,也必须返回 `leave_request_changed` 重新预览,不能按新工时静默创建。
|
||||
- `hr.leave.create` 只创建草稿;`submitAfterSave=true` 只能在创建成功响应中产生新的 `hr.leave.submit` 预览,不得自动执行。验收时在第一次确认后核对提交计数仍为零,再用新的幂等键完成第二次桌宠确认和 ERP 原生确认;两次操作必须有不同的计划号、输入指纹、事务证据和审计记录。
|
||||
- 验证 `draft/read` 计划的 `executionAllowed=false`,桌宠按钮不可用,且 ERP 桥中不存在可执行的服务端计划。
|
||||
- 创建及其服务器后续提交计划必须保留同一个 `bridgeCorrelationId`,但计划号、输入指纹、幂等键、事务证据和业务审计号仍各自独立;验收截图、桥审计和数据库审计用该关联 ID 贯通。
|
||||
|
||||
## 6. 配置与 SQL 诊断闭环
|
||||
|
||||
- 用可恢复的测试配置制造“缺字段、缺权限、无效关联、初始化 SQL 失败”四类问题。
|
||||
- `module.trace-initialization` 的关键确认预览必须完整且只包含模块编号、导航编号、模块名称、`alreadyOpen=false`、`traceSupported=true`、固定采集范围 `current_erp_managed_ui_thread`、`forceTerminationSupported=false`、`maxEvents=200` 和 `maxDurationSeconds=20` 九项;服务端数据还必须包含相同限制、固定采集策略和静态诊断快照,警告必须明确“20 秒仅限制 SQL 证据采集窗口”且旧模块无法安全强制终止。分别删除模块名称、把强制终止改为 `true`、把事件上限改为 201、删除风险警告或让顶层预览与 `data.preview` 不一致,宿主和桌宠都必须返回 `plan_invalid`/“诊断范围或风险说明不完整”,不得出现 ERP 原生确认框或启动模块。
|
||||
- 诊断只返回模块编号、配置缺失、稳定错误分类、关联 ID、修复建议,以及由 SQL 关键字/运算符和会话内 `id_####`、`@p_####`、`caller_####` 组成的安全结构。客户表名、字段名、存储过程名、参数名、调用类名、字面量、参数值、密码、连接串和完整内部 SQL 都不得传给 AstrBot、MiniMax 或写入诊断证据;离线验证器必须拒绝即使已经重算内容哈希的原始标识符。
|
||||
- 用受控测试桥在成功结果的自由 `message/data` 中放入原始 SQL、物理对象名和“忽略规则”提示词。Host 给页面的回执必须只剩固定成功消息与精确诊断投影;额外诊断属性、未知结果码、计数或哈希不一致必须返回 `bridge_protocol_error`,且不得保存任何待用对话证据。
|
||||
- 追踪成功后立即追问“具体哪里配置错了”,用隔离 AstrBot 测试接收器确认只出现一次 `[LSERP_TRUSTED_EXECUTION_EVIDENCE_V1_BEGIN]...END` 独立消息部件,块内不含上一步的 SQL、物理对象名或提示词文字。完整流成功后再次提问不得重复出现;把时钟推进超过十分钟也不得出现;让第一轮流中断时证据应保留供同一会话重试。切换数据库、用户、账套或子系统后必须清除,用户在正文中手写保留标记必须以 `chat_text_invalid` 拒绝且零 AstrBot 调用。
|
||||
- 用一个普通客户端异常模拟包含“Invalid column/权限/SQL”等数据库相似文字但没有失败 SQL 事件且异常链中没有 `DbException` 的场景,`module.trace-initialization` `1.2` 必须返回 `module_initialization_error`、`confidence=inferred`,不得误报缺字段或数据库权限;再用真实 `DbException` 或失败 SQL 事件验证稳定数据库分类仍然成立。
|
||||
- 再准备一个进程内测试模块:直接使用 ADO.NET 触发数据库异常,在模块内部捕获后只调用旧 `LogHelper.WriteError`,同时让窗体仍可打开。追踪必须通过同一 UI 线程的短时日志观察取得真实 `DbException` 分类并把结果标为数据库错误,但响应、证据、审计和模型上下文不得出现原始异常消息、连接串、表名、字段名或 SQL。把相同日志移到后台线程时不得被当前 UI 追踪误收;把异常换成仅在文本中写“Invalid column”的普通异常时只能返回推断性客户端结论。确认页的 `capturePolicy` 必须明确:SQL 明细只覆盖 `Lskj.Core.SqlHelper`,直接 ADO.NET 最多取得同线程已记录的异常分类,不能宣称覆盖其 SQL 文本。
|
||||
- 分别准备独立 EXE、网页/外部资源、旧版原生 LSP 和动态启动目标的隔离菜单配置;追踪计划必须返回 `valid=false`、`executionAllowed=false`、`outcomeCode=module_trace_scope_unsupported`,不弹确认、不启动目标且不生成伪 SQL 证据。再把一个已计划的进程内模块在确认前改为外部边界,执行必须以 `module_configuration_changed` 失败且零启动;恢复后重新计划。`DllName`、URL 和参数不得出现在桥响应或审计中。
|
||||
- 非管理员只能获得用户级错误说明;管理员证据查询也必须记录审计。
|
||||
- 计划响应不得出现 `MenuId/DllName/PurviewId/UrlParams`、原始异常消息或 CLR 异常类型;确认后修改菜单配置或任一低代码静态诊断配置(包括把初始化 SQL 改成相同长度的其他内容),执行必须通过服务端私有完整配置指纹在打开模块、采集 SQL 和写证据前返回 `module_configuration_changed` 并要求重新预览。私有指纹和参与哈希的 SQL/表/字段不得投影到计划、审计或模型上下文;仅数据库字段行返回顺序变化不得误报。提前打开模块必须返回 `module_already_open`。
|
||||
- 重复故障必须按错误码、SQL 指纹和调用点归并并保留出现次数;超过 20 秒证据窗口/200 条时返回 `trace_truncated`,AstrBot 必须把 `confidence=inferred` 和截断结果表述为待复核,不得生成修复 SQL。必须向验收人员明确:20 秒不是模块打开硬超时,无法安全强制终止卡住的旧 WinForms 初始化;疑似卡死场景只允许在可回滚的隔离测试环境复现。
|
||||
- 验收包保留 `diagnosticId`、桥 `correlationId`、`primaryFindingCode`、静态诊断和脱敏事件;使用相同管理员、账套、子系统及测试数据复现。
|
||||
- 检查 `Log/AgentBridge/diagnostics/<diagnosticId>.json` 为不可覆盖单文件、内容不超过 2 MB、SHA-256 与 `content` 一致且身份作用域正确;模拟目录只读/磁盘失败时返回 `evidencePersisted=false` 和 `*_evidence_unavailable`,不得自动重新打开模块。生产目录应限制为 ERP Windows 用户/运维审计账号,并配置留存、归档和安全删除周期。
|
||||
- 在未配置数据库的隔离机运行 `lserp-cli diagnostics verify-evidence --input <file>`,必须成功且不触发 ERP 登录;分别注入重复属性、JSON 注释、未知字段、错误计数、身份范围篡改和哈希篡改,必须返回稳定非零错误。输出的 `signatureVerified=false` 需由外部签章流程补齐。
|
||||
|
||||
## 7. 交付证据
|
||||
|
||||
每个客户/账套保留一套不含业务隐私和秘密的验收包:
|
||||
|
||||
- 已人工签字的字段映射与存储过程版本哈希。
|
||||
- 采购、请假只读契约探针证据及离线哈希校验结果。
|
||||
- 每个启用工作流的 RSA-SHA256 签名验收清单、TrustedPeople 公钥证书 thumbprint、有效期,以及与 V2 验收证据行相同的 `evidence_sha256`、账套和子系统;清单还必须绑定最终部署的 `business-adapters.json`、客户 1.2 只读画像、字段映射、只读契约与写集成报告的原始字节 SHA-256。签发前最终 CLI 必须在线复核画像、本工作流批准状态与零未关闭阻断项,签发脚本还会校验每个阻断解决哈希与清单的精确制品哈希一致,并要求画像选定模块与清单 `moduleCode` 逐字一致。运行时也会在启动、计划和确认执行前重新检查批准状态、模块、解决哈希,并以 V3 就绪查询核对当前过程签名、修改时间及只读系统目录;配置、画像阻断状态、模块、证据文件、关键列或过程参数变化后必须重新签收,旧表布尔值不得作为启用依据。
|
||||
- 权限、匹配、冲突、事务回滚、幂等和审计的自动化测试报告。
|
||||
- 先在客户已验证备份/恢复且明确非生产的可恢复 UAT 库,由提升权限的 Windows PowerShell 5.1 使用 `New-WorkflowUatAuthorization.ps1` 签发最长 24 小时授权。授权必须精确绑定客户、环境、ERP 用户、运行配置、画像、发布策略、最终 ZIP 以及签名 ERP/CLI,并为采购 13 项、请假 19 项固定用例分别生成不可复用令牌。令牌库由 DPAPI CurrentUser、受限 NTFS ACL 和高完整性标签保护,只供现场采集器读取,不能进入 ZIP、聊天、日志或总验收目录;生产库禁止签发或加载该授权。
|
||||
- 使用 `New-WorkflowWriteUatCampaign.ps1` 建立受限、不可覆盖的固定活动目录;生成器与 `Test-WorkflowWriteUatCampaign.ps1` 必须共同验证随包 `workflow-write-uat-case-catalog.v1.json` 的内置 SHA-256,活动清单也绑定同一哈希及管理员验签 CLI、受限运行 CLI 两种身份。每次开始或恢复前,检查器用管理员 CLI 离线验签,只用 `lserp-agent-cli.exe` 运行 `version` 和桥 `health`,并验证 ERP PID、令牌库 ACL 与覆盖、已有单用例和完整用例关系。活动工具没有 execute 路由、不解密令牌且绝不批量写库;32 项仍逐项由验收人员按目录准备场景、确认、DBA 只读复核和采集。版本化目录不含客户数据或可执行 SQL,活动目录含受限测试输入;两者都不属于最终 23 个制品,验收结束后按客户数据销毁流程处理活动目录。
|
||||
- 单用例原始响应由 `Invoke-WorkflowWriteCaseCapture.ps1` 采集:管理员 `lserp-cli.exe` 只验证授权并投影证据,受限 `lserp-agent-cli.exe` 才能执行 `version` 与 `bridge health/context/plan/execute`。脚本分别锁定并验证两者的 SHA-256/签发者,绑定运行 CLI 的精确版本和 ERP PID,以同一关联 ID 自动完成采购或请假的 `resolve -> create`,授权令牌和幂等键都只走标准输入。服务器 `resolutionProof` 只在受限临时目录中用于后续计划,最终仅保留 `schemaVersion=1.3` 脱敏索引投影;DBA 观察值仍由验收人员提供。采购提交/重放/审计证据会再次独立校验完整创建 Schema、明确成功结果以及计划附件数量/集合摘要,缺少发票日期、金额或明细时不能靠成功标签通过。
|
||||
- 由 `New-WorkflowWriteIntegrationEvidence.ps1` 生成且经最终 `lserp-cli adapters verify-write-integration-evidence` 验证的 `schemaVersion=1.6` 采购、请假写集成报告;报告必须绑定相同客户 `schemaVersion=1.2` UAT 授权的原始文件/内容/授权 ID 哈希、每个固定用例唯一令牌哈希和运行 CLI 版本/SHA-256/签发者,以及相同源码提交、商用 ZIP、运行时配置、模块、账套和子系统。每个用例还要证明同一 ERP PID 上下文关联 ID、用户编号哈希、用户名哈希、数据库作用域、管理员状态以及实际命令名、版本、风险和固定计划有效期,覆盖采购 13 项、请假 19 项固定场景,且结果码、解析问题码、确认阶段、来源文件贯通、跨用例记录关系、事务、幂等和审计证据全部匹配。
|
||||
- 三条端到端闭环的关联 ID、计划指纹、业务记录 ID 和审计 ID。
|
||||
- 代码签名、安装包哈希、依赖版本、数据保留/销毁策略和回滚手册;商用预检必须是 `schemaVersion=1.7`,其 `packageSha256` 必须等于最终 ZIP,声明 `miniMaxIntegrationMode=direct_https_vlm`,并绑定 guga 授权、AstrBot AGPL/EULA 合规审查、MiniMax API 服务审查,以及预检前 24 小时内合成图片在线探针原始 JSON 的 SHA-256、观测时间、区域和合同版本。预检检查项必须无重复、全部为 `passed=true/code=ok`,且包含当前版本的 `package_archive_binding`、`guga_supply_chain_audit`、`sqlserver_compatibility100_syntax`、`workflow_uat_case_catalog`、`minimax_online_vision_probe_evidence`、`pdf_invoice_pipeline`、`attachment_snapshot_binding` 与 `erp_session_scope`;`guga_supply_chain_audit` 必须证明包内只有锁定的上游审计而没有在线下载的 guga 素材,独立的 `guga_commercial_license_evidence` 仍须绑定实际包外精灵图摘要。即使手工保留顶层 `passed=true`,缺少门禁、目录/探针过期或篡改、或来自同一源码提交下另一个 ZIP 的旧报告也不能进入客户总验收包。
|
||||
|
||||
最后把以下 23 个文件以互不重复的纯文件名放进同一个只读目录:最终商用 ZIP、`business-adapters.json`、客户只读画像、最终命令发布策略、商用预检、MiniMax 在线探针、`LEGACY-BUILD-EVIDENCE.json`;采购的单工作流 UAT 授权、签名清单、写集成报告、1.5 只读预检和 1.1 现场交接;请假的同五份文件;诊断管理员会话的 1.5 只读预检、1.1 现场交接和四份诊断原始证据。审批输入原文和令牌库不属于制品。执行 `New-CustomerAcceptanceBundle.ps1` 时除管理员 `-VerifierCliPath` 外,还必须提供从最终 ZIP 解包且已签名的 `-RuntimeCliPath`、`-ExpectedRuntimeCliVersion`,以及两份 UAT 文件、三组会话文件和三个子系统。生成器会从最终 ZIP 内的 `SHA256SUMS.json` 读取 `Host/lserp-agent-cli.exe` 条目,核对外部运行时文件的版本、大小、SHA-256、Authenticode 签发者和实际 `version` 响应,并要求三份预检及交接绑定同一运行时身份。随后写入 `schemaVersion=1.8` 临时总包,并由管理员验证 CLI 离线复核 23 个文件后才原子发布。采购交接只能声明采购角色,请假交接只能声明请假角色,诊断交接只能声明诊断角色且预检必须为管理员;支持模块可同会话存在,但不能冒充业务角色。任何运行时/验证器角色混用、跨会话交换、合并授权、串子系统/PID/用户、串提交、串 ZIP、串数据库作用域、证据篡改或签名失败都不会发布输出文件。
|
||||
|
||||
在隔离验收机安装相同 TrustedPeople 公钥证书后复验:
|
||||
|
||||
```text
|
||||
lserp-cli acceptance verify-customer-bundle --input customer-acceptance.json --evidence-root <验收目录> --source-commit <40位提交> --package-sha256 <最终ZIP哈希> --account-book <账套> --purchase-subsystem <采购子系统> --leave-subsystem <请假子系统> --diagnosis-subsystem <诊断子系统> --database-scope-fingerprint <已核准的64位数据库作用域指纹>
|
||||
```
|
||||
|
||||
成功输出仍固定 `registrationReady=false`。总包是发布与客户签收证据,不替代 ERP 启动、计划和确认时对当前 V3 就绪结果及其 V2 验收证据行、低代码配置、权限和运行时过程的再次检查。
|
||||
@@ -0,0 +1,117 @@
|
||||
# 朗速 ERP 智能桌宠 Windows x64 验收包
|
||||
|
||||
交付包内的 1.1 线协议声明 v3 `sessionScopeToken` 及其六项绑定字段;能力、计划和执行请求由客户端携带、由 ERP 服务端权威复核,缺失或错绑时不会进入业务处理。
|
||||
|
||||
本包只用于受控客户验收,不能仅凭“程序能启动”判定可商用。`BUILD-VERIFICATION.json` 记录构建机自动化结果,`SHA256SUMS.json` 绑定包内全部文件;最终上线仍必须在客户 Windows、旧 ERP、DevExpress 15.2 和可恢复 SQL Server 验收库中完成真实集成测试。
|
||||
|
||||
## 包内容
|
||||
|
||||
运行时就绪复核也遵循显式会话边界:计划和执行会把当前 `CommandExecutionContext` 传给支持上下文扩展的业务适配器,再由 SQL 网关在真实连接上核对用户、账套、子系统和数据库作用域;旧适配器只保留启动注册兼容路径,不能替代生产网关的连接复核。
|
||||
|
||||
- `Host/`:自包含 .NET 8 WPF + WebView2 桌宠宿主,以及独立的 `lserp-agent-cli.exe` 本地桥 CLI。CLI 只提供 `bridge/workflow` 命令,只连接当前已登录 ERP 的同用户命名管道;不接收数据库口令、不直连 SQL Server。每次桥调用必须完整传入精确 ERP PID、数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值;实际权限和模块配置仍由 ERP 会话权威注入,CLI 会把调用方预期范围与实际上下文逐项比对。除 `bridge context` 自身外,CLI 在目标命令前后各读取一次上下文,身份、账套、子系统、数据库作用域或管理员属性漂移时丢弃目标结果;无 UI 副作用的读取/计划还要求当前与已打开模块集合稳定,而执行调用允许导航或初始化追踪按可信计划产生的 UI 变化。CLI 自报版本来自构建时程序集身份而非源码常量,现场预检会实际执行 `version` 并逐字段绑定包版本、协议和安全边界。透明窗口使用合成 WebView2,并启用受控拖动区域和严格退出消息;HTML/CSS/JavaScript 已嵌入签名程序集并从内存提供,不依赖可变外部 Web 目录。
|
||||
- `AstrBotPlugin/`:精确锁定 AstrBot `4.27.2` 的 ERP 安全工具插件、上游来源契约及 Python 依赖版本。
|
||||
- `Contracts/`:ERP Bridge、AstrBot 和桌宠共同验证的 `erp-agent-wire-contract-v1.json`;固定 15 个计划字段并覆盖采购、请假、模块诊断、动态模块新增/并发修改和导航八个跨组件样本,现场预检拒绝缺失或漂移。
|
||||
- `Deployment/`:旧 ERP/CLI 可重复构建脚本及其离线正负契约测试、SQL Server 契约、采购/请假写用例模板、第三方合规门禁、签名工作流/客户总验收生成器、默认拒绝的 `command-rollout.example.json`,以及精确 PID/数据库作用域/用户/账套/子系统绑定的只读动态合同预检、短时 UAT 授权与单用例实机采集器。旧 ERP 构建脚本会在签名前扫描最终一方 EXE/DLL 的 ASCII/UTF-16 字符视图,发现成对硬编码 SQL 用户名/口令时以 `legacy_runtime_hardcoded_sql_credential:<文件名>` 失败关闭且不回显凭据。`Invoke-LserpSelectOnlyProfilePreflight.ps1` 用受控 `PSCredential` 一次完成严格 SELECT-only 目录采集和最终签名 CLI 离线画像比对,锁定画像/采集器/CLI 的哈希与签发者,只发布 ACL 受限且不覆盖的哈希快照和脱敏报告,始终不授权写命令。`Invoke-LserpFieldReadOnlyValidation.ps1` 严格读取默认拒绝的 `field-readonly-validation.example.json` 副本,要求独立审批和 24 小时内时效,同时锁定输入与底层预检脚本哈希,把 PID、二进制身份、会话范围、发布策略及采购/请假/诊断到客户实际模块的角色绑定完整交给只读预检;其报告只含哈希和计数。`Invoke-LserpReadOnlySessionPreflight.ps1` 只允许签名 CLI 调用 `bridge health/context/capabilities` 和不可执行的 `module.parameters` 计划,并只发布会话哈希、合同/配置/原生执行指纹、参数模式/载荷数量、经审核的保存族和新增/修改阻断状态。`FIELD_VALIDATION_RUNBOOK.md` 给实施人员提供分阶段现场放行和统一停止条件,完整证据要求仍以 `CUSTOMER_ACCEPTANCE.md`、`WRITE_ACCEPTANCE.md` 为准。写采集器绑定签名授权、DPAPI 令牌库、签名 CLI 和精确 ERP PID,以同一关联 ID自动完成 `resolve -> create`;UAT 令牌和幂等键仅走标准输入,只发布含授权/作用域哈希和计划合同的脱敏证据并清理受限原始目录。目录还包含仅有对象/字段名且默认禁用的 `customer-profiles` 人工复核材料;其中 compat100 只读、请假强类型写和采购固定 XML 行集写草案均以 `SET NOEXEC ON` 和固定关闭审核开关保护,采购草案另有固定关闭的行级范围开关,只供 DBA 评审,不能直接部署。
|
||||
- 只读会话预检必须使用最终 ZIP 中的受限 `Host/lserp-agent-cli.exe`,并用 `-ExpectedCliVersion`、CLI SHA-256/签发者、`-ExpectedErpSha256` 和 PID 锁定运行时;脚本会实际运行 `version`,要求 `bridgeOnly=true`、`databaseDirectAccess=false`,再把经审批的数据库作用域、用户编号、用户名、账套、子系统和管理员布尔值作为每个 CLI 子进程的必填参数传入。CLI 本身和预检包装器都会在读取能力和模块之前逐字复核实际 ERP 上下文,且 CLI 对目标调用执行前后复核。诊断工作流必须明确批准管理员会话。1.5 报告只保留运行时 CLI 版本/哈希/签发者、ERP 身份、会话域分离哈希和脱敏动态执行证据,不保留本机路径、原始会话标识、字段标签、参数 ID、业务值或物理配置;旧 1.4 及更早报告不能用于当前总验收。
|
||||
- MiniMax 图片识别由 AstrBot 服务进程使用固定区域的 HTTPS VLM;线协议绑定官方 `minimax-coding-plan-mcp 0.0.4` 提交并发送 `MM-API-Source: Minimax-MCP`,实际发送字节必须命中识别前附件 SHA-256/大小。电子 PDF 先在隔离 PDFium worker 中完整渲染最多三页,再逐页识别并绑定原 PDF/页图/结构结果摘要;XLSX/CSV worker 同样只解析一次验签后的内存快照。交付包不含 `minimax-coding-plan-mcp`、`mmx-cli`、Node.js 或 MiniMax Key。
|
||||
- `Verify-LserpCommercialPackage.ps1`:只读现场预检;同时锁定最终 ZIP 与解包目录,逐文件复核清单、大小和 SHA-256。
|
||||
- `Start-LserpAgentPet.ps1`:通过全部关键启动门禁后启动桌宠。
|
||||
|
||||
## 本包刻意不包含
|
||||
|
||||
- MiniMax Key、AstrBot Key、数据库口令或连接串。
|
||||
- 完整管理员/验收工具 `lserp-cli.exe`、改造后的 `Ls_ERP.exe`、AgentBridge/CommandKernel/Core 旧框架二进制。本 ZIP 内的 `Host/lserp-agent-cli.exe` 只是无数据库直连能力的运行时桥 CLI;完整 .NET Framework 4 x86 CLI 和 ERP 组件必须从同一干净提交在客户 Windows 构建机使用 `Deployment/Build-LegacyErpAcceptance.ps1` 重建、签名并作为独立 `LegacyArtifactRoot` 交给预检。`BUILD-VERIFICATION.json/deliveryTopology` 会分别声明两个 CLI 的边界;缺少独立签名旧构建时启动器不会运行桌宠。
|
||||
- guga/codex-pets 精灵图。`Deployment/guga-upstream-audit.v1.json` 已锁定 `codex-pets 0.3.0`、服务源码和当时在线素材的摘要,并证明安装器不校验素材摘要/签名/授权;它不是商用许可证。该素材没有随安装结果提供可核验的商用授权文件,禁止在生产机直接运行 `npx codex-pets add guga`,必须先取得权利人的书面商用许可,再经公司受控制品渠道由客户在本机单独提供素材路径和授权证据。
|
||||
- 可直接启用的客户业务字段配置、真实发票、员工请假原因或生产数据库数据。`customer-profiles` 只保存从系统目录和低代码配置提取的脱敏候选;仅当客户完成在线复核、签名清单绑定,并在最终 `business-adapters.json` 1.1 中显式设置 `customerProfilePath` 时,指定画像才会作为运行时只读目录门禁加载,候选映射和 SQL 草案永远不会自动执行。
|
||||
- 未经客户签收的 `business-adapters.json` 和写工作流签名验收清单。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 从与本包 `BUILD-VERIFICATION.json/sourceCommit` 相同的已审查干净提交,在装有 .NET Framework 4 Targeting Pack、Visual Studio MSBuild 和 MSVC 工具的客户 Windows 构建机运行:
|
||||
|
||||
```powershell
|
||||
.\Deployment\Build-LegacyErpAcceptance.ps1 `
|
||||
-RepoRoot D:\ReviewedSource\lserp_cs_6.0 `
|
||||
-OutputDirectory D:\Acceptance\legacy-erp-build `
|
||||
-ExpectedSourceCommit <40位提交号> `
|
||||
-CefRedistPackagePath D:\ApprovedPackages\cef.redist.x86.87.1.13.nupkg `
|
||||
-AuthenticodeCertificateThumbprint <40位证书指纹> `
|
||||
-CertificateStoreLocation CurrentUser `
|
||||
-TimestampUrl https://<客户批准的RFC3161时间戳服务>
|
||||
```
|
||||
|
||||
脚本拒绝脏工作树和已有输出目录;它从指定提交创建隔离的本地克隆,按解决方案 `Release|Mixed Platforms` 映射重建 `lserp-cli` 及其 `Ls_ERP`/AgentBridge/CommandKernel 项目依赖,避免把只支持 `AnyCPU` 的老依赖错误地强制成 x86。CEF 包必须是与现有 `Xilium.CefGlue 87.1.1` 绑定配套的 `cef.redist.x86 87.1.13` 原始 nupkg,SHA-256 必须为 `34dfe2504c1ffaef02eab1f38578701b045439349997b6465fd5dd6659fab021`;脚本只在隔离克隆中安全解包,不改动审查工作树,并把完整原生 CEF 目录复制到 Runtime,逐一拒绝冲突旧文件并复核关键 DLL、数据文件和中英文 locale 哈希。它自动定位当前 VS 的 `MSBuild.exe`、`editbin.exe` 和 Windows SDK `signtool.exe`,随后直接读取 PE/CLR 头验证 .NET 4、x86、`32BITREQUIRED`、ERP `LargeAddressAware` 以及 CefGlue/CEF 主版本。关键 EXE/DLL 在生成最终哈希前统一签名并验证证书指纹;输出 `LEGACY-BUILD-EVIDENCE.json` 和签名后逐文件 SHA-256,仍保持 `releaseReadiness=false`。测试构建可不传签名参数,但不会通过商用预检。
|
||||
|
||||
CEF 87 只用于兼容当前旧 WinForms 绑定,不代表通过现代浏览器安全评估。升级到受支持的 CefGlue/CEF 组合前,必须禁止打开公网和其他不受信任页面;正式广泛商用需完成升级或由客户安全负责人书面接受限定内网页面的遗留风险。
|
||||
|
||||
正式构建前可在源码根目录先运行 `./Deployment/Test-DeploymentContracts.ps1 -RepoRoot <源码目录>`;它不构建或写入 ERP,只验证当前仓库和多个故意破坏的临时夹具,任一负例未被拒绝都会返回非零退出码。
|
||||
|
||||
2. 按客户发布流程签署最终安装包,并保存安装包哈希;不得在 `LEGACY-BUILD-EVIDENCE.json` 生成后再次修改其中列出的 Runtime 文件。
|
||||
3. 在客户可恢复验收机上安装 Microsoft Edge WebView2 Evergreen Runtime `151.0.4129.50` 或更高版本。宿主锁定 WebView2 SDK `1.0.4129.50`,预检会解析注册表中的实际 Runtime 版本并拒绝更早或格式异常的版本,不能只以“已经安装”代替兼容性检查。
|
||||
4. 使用同一客户发布证书签署 `Host/Lskj.AgentPet.Host.exe`、`Lskj.AgentPet.Host.dll`、`Lskj.AgentPet.Host.Core.dll` 与单文件自包含的 `lserp-agent-cli.exe`,记录 40 位证书指纹;四者任一未签、签名失效或签发者不同都会被预检和启动器拒绝。正式包禁止只签桌宠启动 EXE,因为桌宠业务逻辑位于两个一方 DLL 中;桥接 CLI 的托管代码和依赖则完整包含在其签名 EXE 内。
|
||||
|
||||
在客户 Windows 构建机生成最终桌宠包时,为 `Build-CommercialPackage.sh` 设置 `LSERP_HOST_CERT_THUMBPRINT`、`LSERP_HOST_TIMESTAMP_URL`,以及按需设置 `LSERP_HOST_CERT_STORE`/`LSERP_HOST_SIGNTOOL`。同时必须把 `LSERP_ASTRBOT_SOURCE` 指向企业制品库还原的官方 AstrBot 干净 `v4.27.2` 源码绝对路径,把 `LSERP_ASTRBOT_CONTRACT_PYTHON` 指向已安装该源码运行依赖的受审 Python 绝对路径。打包器会复核官方 HTTPS remote、标签、提交 `ad4fbfa90ca0c4ac2b30b3250e34dbf8fe7babbf`、工作树、许可证 SHA-256、20 个关键源码摘要和实际模块来源,再用真实 AstrBot 类型实例化插件与三个 Tool;缺少这两个输入、仅运行本地 stub 测试或上游源码有任何漂移都会终止。该上游 checkout 只用于构建取证,不会装入交付包。CI 的 Node 工具不在标准 `PATH` 时可用 `LSERP_NPM` 绑定审核过的 npm 绝对路径,避免构建机隐式选择其他版本。脚本会发布自包含桌宠,并把桥接 CLI 压缩为唯一的 Windows x64 单文件自包含 EXE,拒绝残留独立 DLL、deps 或 runtimeconfig;随后在计算 `SHA256SUMS.json` 和 ZIP 哈希前调用 `Deployment/Sign-LserpAgentPetHost.ps1`,使用 SHA-256 与 RFC3161 时间戳签署并复验四个一方文件。签名参数只给一部分会直接失败。未提供参数生成的包只能用于自动化验收,`hostAuthenticode.signed=false`,不能进入商用预检。
|
||||
5. 只在精确的 AstrBot 4.27.2 实例中把 `AstrBotPlugin` 复制到 `data/plugins/astrbot_plugin_lserp`。本包的 `PythonWheels/` 是按 `requirements.txt` 中固定 SHA-256、明确以 `win_amd64` 平台下载并由包清单再次逐文件绑定的离线依赖;构建机测试使用单独 wheelhouse,不能把 macOS/Linux wheel 混入 Windows 包。打包脚本会在临时隔离 venv 中安装本机受审 wheel、执行 `pip check`,并以 PDFium/XLSX worker 测试零跳过为硬门禁;客户预检再核对 `pypdfium2-5.12.1-py3-none-win_amd64.whl` 的精确哈希、`pdfium.dll` 及 wheel 内许可证清单。在 AstrBot 专用虚拟环境执行 `python -m pip install --no-index --require-hashes --find-links .\PythonWheels -r .\AstrBotPlugin\requirements.txt`。正式发布还应由制品库保留 AstrBot 与插件依赖的上游来源、签名/恶意软件扫描和 SBOM。插件 metadata 与进程内守卫都会拒绝其他 AstrBot 版本。
|
||||
6. 不安装 Node.js、`mmx-cli` 或 `minimax-coding-plan-mcp`。把 AstrBot 插件的 `minimax_api_region` 明确设为购买 Key 的 `global` 或 `cn` 区域,只把轮换后的 `MINIMAX_API_KEY` 注入 AstrBot 专用服务账号的秘密环境;桌宠进程不得继承。先在该账号中运行 `python AstrBotPlugin\verify_minimax_vlm_contract.py --region <global|cn> --output <新文件.json>`,用无客户数据的合成图片验证在线合同并把报告 SHA-256 纳入 MiniMax 服务审查记录。插件只连接固定 MiniMax HTTPS VLM,禁用系统代理和重定向,并在网络请求或文档解析前把实际读取的单一字节快照与消息识别前来源 SHA-256/大小逐字比较。打包器会拒绝五套自动化测试数量低于当前受审基线,也会验证交付包不存在 `MmxRuntime`。制品仍应进入客户软件成分分析和恶意软件扫描流程。
|
||||
7. 通过 Windows“凭据管理器”创建通用凭据 `Langsu.Lserp.AstrBot.ApiKey`,只授予 AstrBot `chat + file` scopes。不要把 Key 写进 PowerShell 参数、环境变量、配置文件或日志。
|
||||
8. 按 `Deployment/CUSTOMER_ACCEPTANCE.md` 完成客户字段映射、只读契约探针、写事务/幂等/审计和签名证据门禁。先复制并独立审核 `field-readonly-validation.example.json`,用原始文件 SHA-256 调用 `Deployment/Invoke-LserpFieldReadOnlyValidation.ps1`;先带 `-ValidateInputOnly` 验证交接合同,再换新报告路径执行底层只读会话预检。文件明确绑定最终签名 CLI、精确 ERP PID、人工核准的数据库作用域指纹、用户 ID、账套、子系统、管理员属性、发布策略客户 ID/SHA-256,以及采购、请假、诊断角色对应的客户实际模块编号;不同用户会话分文件批准。第一次发现用 `discovery`,适配器注册后用 `final` 并打开实际验收工作流。脚本没有数据库连接参数且不调用执行路径,报告固定不授权生产写入。然后只在已验证备份/恢复且明确非生产的客户 UAT 库,用提升权限的 Windows PowerShell 5.1 运行 `Deployment/New-WorkflowUatAuthorization.ps1`,显式确认五项安全开关并签发最长 24 小时授权;令牌库只留在同一受控验收账号,不能进包、日志或聊天。将授权路径/哈希通过 `LSERP_WORKFLOW_UAT_AUTHORIZATION`、`LSERP_WORKFLOW_UAT_AUTHORIZATION_SHA256` 注入并重启 ERP。随后保持随包 `Deployment/workflow-write-uat-case-catalog.v1.json` 原始字节不变,用 `New-WorkflowWriteUatCampaign.ps1` 生成固定 13+19 项、生产禁用、不会自动写库的受限活动清单;生成器、活动清单与恢复检查器三处绑定同一目录 SHA-256。每次开始或恢复先运行 `Test-WorkflowWriteUatCampaign.ps1`,只读复核目录、授权、CLI、令牌库 ACL/覆盖、精确 ERP PID、已有单用例语义和断点,再按其唯一 `nextCase.operatorGuide` 准备并单独运行 `Invoke-WorkflowWriteCaseCapture.ps1`。禁止循环批量执行 32 项;采集器会验证授权、令牌库、签名 CLI 和精确 ERP PID,令牌/幂等键只走标准输入,但不会替代 DBA 对业务变更数、审计数和来源附件贯通的只读复核。三项确认后漂移场景还必须用交互式 `-PauseAfterPlanForOperatorStaging` 留出 DBA/配置人员阶段,工具本身不修改配置。先把 `Deployment/command-rollout.example.json` 复制到包外受 ACL 保护的客户目录,逐条替换并复核客户部署标识、账套、子系统、精确用户/内置管理员、命令版本和原 ERP 权限契约,对最终原始文件计算 SHA-256;设置 `LSERP_AGENT_ROLLOUT_CONFIG`、`LSERP_AGENT_ROLLOUT_SHA256`、`LSERP_AGENT_ROLLOUT_CUSTOMER_ID` 后,ERP 进程才可通过 `LSERP_AGENT_BRIDGE_ENABLED=1` 显式开启同用户命名管道桥。缺少发布文件、哈希/客户不匹配、`defaultAction` 非 `deny`、规则引用未注册命令或版本/权限漂移时都不发布桥。上线前还要按客户容量和故障演练审批各项限流/熔断参数;非法值不发布桥,变更后必须重启 ERP。
|
||||
写入 UAT 的活动生成器、恢复检查器和逐用例采集器同时锁定两类 CLI:最终签名的管理员 `lserp-cli.exe` 只做授权、观察和证据的离线验证;最终 ZIP 中的受限 `lserp-agent-cli.exe` 只做 `version` 与 `bridge health/context/plan/execute`。两者路径、哈希和签发者分别校验且不得互换,每项原始索引与最终报告还会绑定实际运行 CLI 的版本、SHA-256 和签发者。
|
||||
运行只读会话预检时必须传入最终 `Ls_ERP.exe` 的 `-ExpectedErpSha256` 以及 `-ExpectedUserId`、`-ExpectedUserName`、`-ExpectedAccountBook`、`-ExpectedSubSystemId`、`-ExpectedIsAdministrator`,并确保 ERP 与 CLI 的签发者同为客户批准证书。
|
||||
桌宠实机验收还要记录预览显示的 `bridgeCorrelationId`,确认 resolve/create、页面执行、ERP 原生确认、命令审计和服务器后续计划使用同一个值;篡改页面关联 ID 的负例必须在命名管道前失败且零业务变更。
|
||||
两个工作流与四类诊断完成后,把文档规定的 23 个原始制品放在同一只读目录。采购、请假各自需要单工作流 UAT 授权、1.5 只读预检和绑定该文件 SHA-256/运行时 CLI 身份的 1.1 现场交接;诊断另需管理员会话预检与交接。运行 `Deployment/New-CustomerAcceptanceBundle.ps1` 时还要分别传入管理员 `-VerifierCliPath`、最终 ZIP 中的受限 `-RuntimeCliPath` 和 `-ExpectedRuntimeCliVersion`。生成器从 ZIP 清单核对运行时 CLI 并写入严格 `schemaVersion=1.8`,再由最终签名管理员 CLI 离线复验全部 23 个文件;运行时/验证器角色混用、跨子系统交换预检、合并授权、复用交接或把普通业务会话冒充诊断管理员都会被拒绝。总签章不会直接启用写命令。
|
||||
如果要发布配置驱动的通用模块新增,还必须按 `Deployment/DYNAMIC_MODULE_WRITE_ACCEPTANCE.md` 部署默认拒绝的 `004_dynamic_module_adapter_contract.sql`,在可恢复 UAT 库逐模块验收全字段、回滚、持久幂等、权限/配置漂移、原生校验/默认值/编号/钩子和审计,再用 `New-DynamicModuleWriteAcceptance.ps1` 对精确模块/配置指纹清单做 TrustedPeople RSA-SHA256 签名。客户 DBA 写入同一摘要的模块级就绪行、发布策略精确允许 `module.record.create` 且三个 `LSERP_DYNAMIC_MODULE_WRITE_*` 启动值一致后,已登录 ERP 桥才会对该模块返回可执行写计划。此流程不授权通用修改、删除或提交。
|
||||
9. 取得 guga 素材书面商用授权,并确认授权明确绑定实际精灵图 SHA-256、权利人身份、商业产品使用、客户部署、复制、展示、地域和期限后,在 PowerShell 运行。不要把 npm 的 MIT 声明当成图片授权,也不要在客户生产机用 `npx` 临时下载可变素材:
|
||||
|
||||
```powershell
|
||||
.\Verify-LserpCommercialPackage.ps1 `
|
||||
-PackageArchivePath D:\ApprovedPackages\Lserp-AgentPet-@LSERP_PACKAGE_VERSION@-win-x64.zip `
|
||||
-ExpectedPackageVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-LegacyArtifactRoot D:\Acceptance\legacy-erp-build `
|
||||
-RolloutPolicyPath C:\ProgramData\Langsu\AgentBridge\command-rollout.json `
|
||||
-RolloutCustomerId CUSTOMER-001 `
|
||||
-SpritePath C:\SecureAssets\guga\spritesheet.webp `
|
||||
-SpriteLicenseEvidence C:\SecureAssets\guga\commercial-license.pdf `
|
||||
-AstrBotComplianceEvidence C:\Compliance\astrbot-agpl-eula-review.pdf `
|
||||
-MiniMaxServiceComplianceEvidence C:\Compliance\minimax-api-service-review.pdf `
|
||||
-MiniMaxVisionProbeEvidence C:\Compliance\minimax-vision-probe.json `
|
||||
-HostCertificateThumbprint <40位客户发布证书指纹> `
|
||||
-ErpProcessId 1234 `
|
||||
-ExpectedDatabaseScopeFingerprint <64位数据库作用域指纹> `
|
||||
-ExpectedUserId <当前ERP登录用户ID> `
|
||||
-ExpectedUserName <当前ERP登录用户名> `
|
||||
-ExpectedAccountBook <当前ERP账套> `
|
||||
-ExpectedSubSystemId <当前ERP子系统ID> `
|
||||
-ExpectedIsAdministrator <true|false>
|
||||
```
|
||||
|
||||
脚本会锁定 `-PackageArchivePath` 指向的最终 ZIP,计算其 SHA-256,并拒绝路径越界、大小越界、重复/额外条目、链接条目以及与当前解包目录 `SHA256SUMS.json` 不一致的任何文件;`schemaVersion=1.7` 预检报告据此绑定精确 ZIP、24 小时内的 MiniMax 原始探针和锁定 ScriptDom `TSql100` 语法证据,而不是只绑定相同源码提交或“脚本存在”。它会用包内最终签名的 `Host/lserp-agent-cli.exe` 实际调用目标 ERP 的只读 `bridge context/health/context`,而完整 `LegacyArtifactRoot/lserp-cli.exe` 只承担管理员配置和客户验收职责;预检和启动始终使用相同的必填 `-ErpProcessId`。它会调用 AstrBot 同机公开的 `/api/v1/stats/versions`,不发送聊天 Key,并同时要求运行时版本与磁盘代码版本精确等于 4.27.2。除了检查桥可达,它要求 `health.rolloutPolicy` 只有固定的七个安全字段,并证明目标进程实际加载的客户 ID、数据库作用域、默认拒绝状态和原始配置 SHA-256 与现场指定文件一致;任一发布策略证明不一致时,预检以稳定码 `erp_rollout_policy_mismatch` 失败关闭。健康检查前后的会话还必须逐字匹配预期用户编号、用户名、账套、子系统和数据库作用域,但这些原始标识不会写入报告。它会从受控文件句柄计算发布策略哈希,并从同一个禁止写入/删除共享的文件句柄计算素材 SHA-256、同时有界扫描 RIFF/WebP chunk;素材只接受 1536×1872 的静态 VP8X/VP8 或 VP8L 图集,错图、截断、伪装扩展名和符号链接均失败关闭。宿主随后只服务已验证的内存副本,WebView2 还会实际解码压缩流,通过前不会连接 ERP。脚本在包外以 `CreateNew` 写入 JSON 报告,任一硬门禁失败均返回非零退出码。全部通过后启动:
|
||||
|
||||
```powershell
|
||||
.\Start-LserpAgentPet.ps1 `
|
||||
-PackageArchivePath D:\ApprovedPackages\Lserp-AgentPet-@LSERP_PACKAGE_VERSION@-win-x64.zip `
|
||||
-ExpectedPackageVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-LegacyArtifactRoot D:\Acceptance\legacy-erp-build `
|
||||
-RolloutPolicyPath C:\ProgramData\Langsu\AgentBridge\command-rollout.json `
|
||||
-RolloutCustomerId CUSTOMER-001 `
|
||||
-SpritePath C:\SecureAssets\guga\spritesheet.webp `
|
||||
-SpriteLicenseEvidence C:\SecureAssets\guga\commercial-license.pdf `
|
||||
-AstrBotComplianceEvidence C:\Compliance\astrbot-agpl-eula-review.pdf `
|
||||
-MiniMaxServiceComplianceEvidence C:\Compliance\minimax-api-service-review.pdf `
|
||||
-MiniMaxVisionProbeEvidence C:\Compliance\minimax-vision-probe.json `
|
||||
-HostCertificateThumbprint <40位客户发布证书指纹> `
|
||||
-ErpProcessId 1234 `
|
||||
-ExpectedDatabaseScopeFingerprint <64位数据库作用域指纹> `
|
||||
-ExpectedUserId <当前ERP登录用户ID> `
|
||||
-ExpectedUserName <当前ERP登录用户名> `
|
||||
-ExpectedAccountBook <当前ERP账套> `
|
||||
-ExpectedSubSystemId <当前ERP子系统ID> `
|
||||
-ExpectedIsAdministrator <true|false>
|
||||
```
|
||||
|
||||
启动器会先以独立 Windows PowerShell 5.1 进程重新运行完整 `Verify-LserpCommercialPackage.ps1`,复核包清单、签名、指定 ERP 实例、凭据、运行时、素材、MiniMax 直连合同、24 小时探针、附件快照绑定和三份书面第三方合规证据;`-ExpectedPackageVersion` 必须与 `BUILD-VERIFICATION.json` 及最终 ZIP 文件名中的三段版本完全一致,预检非零时不会启动宿主。AstrBot、已移除的 mmx-cli 制品、MiniMax 在线服务与证据要求见 `Deployment/THIRD_PARTY_COMPLIANCE.md`。因此不能通过跳过上一条人工预检命令绕开商用门禁。
|
||||
|
||||
`-ErpProcessId` 和六个 `-Expected*` 会话参数始终必填。脚本会把该 PID、进程启动时间以及由数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值共同派生的 v3 32 位作用域令牌写入本次随机 AstrBot 会话,并把六项预期值显式传给宿主独立重算。宿主直连请求和 AstrBot 三个 ERP Tool 都会在目标操作前后读取上下文:初始不符时不发送业务操作,期间发生切换时丢弃结果。预检前后若指定实例、可执行文件、启动时间或会话作用域变化,桌宠都会失败关闭;即使 Windows 以后复用相同 PID,或同一进程内切换登录,也不能自动转入其他数据库、账套或用户会话。
|
||||
|
||||
发现文件另带每次桥启动随机生成的 32 位小写十六进制 `bridgeInstanceId`,桥只监听 `lserp.agent.<PID>.<bridgeInstanceId>`,发现字段与管道后缀不一致时三端都拒绝。随机后缀保证旧发现记录无法在同 PID 重建窗口误连新桥。该值不进入命令行预期范围、模型上下文或验收报告;CLI 将它纳入 v2 `clientSessionId`,Host 和 AstrBot 将首次代际固定到本次桌宠会话。同一 ERP PID 内停止并重建桥、退出登录或切换账号后,旧桌宠必须在下一次桥调用前得到 `erp_bridge_instance_changed` 并退出;重新运行预检和启动器建立新会话后才可继续,旧计划不得恢复或迁移。
|
||||
|
||||
当前商用启动器和宿主只接受同机 loopback AstrBot,并要求 AstrBot、ERP 和桌宠属于同一 Windows 用户边界。HTTPS 远程地址也不会被当前版本接受,因为服务端插件无法访问客户机命名管道;中央服务模式必须先实现客户端主动出站、具有短期双向设备身份的 Agent Gateway,不能远程暴露命名管道。
|
||||
@@ -0,0 +1,350 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$PackageRoot = $PSScriptRoot,
|
||||
[Parameter(Mandatory = $true)][string]$PackageArchivePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
|
||||
[string]$ExpectedPackageVersion,
|
||||
[Parameter(Mandatory = $true)][string]$SpritePath,
|
||||
[Parameter(Mandatory = $true)][string]$SpriteLicenseEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$AstrBotComplianceEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$MiniMaxServiceComplianceEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$MiniMaxVisionProbeEvidence,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$HostCertificateThumbprint,
|
||||
[Parameter(Mandatory = $true)][string]$LegacyArtifactRoot,
|
||||
[Parameter(Mandatory = $true)][string]$RolloutPolicyPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$')]
|
||||
[string]$RolloutCustomerId,
|
||||
[string]$AstrBotBaseUrl = 'http://127.0.0.1:6185',
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateRange(1, 2147483647)]
|
||||
[int]$ErpProcessId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedDatabaseScopeFingerprint,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 256)]
|
||||
[string]$ExpectedUserId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 256)]
|
||||
[string]$ExpectedUserName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 256)]
|
||||
[string]$ExpectedAccountBook,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 256)]
|
||||
[string]$ExpectedSubSystemId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('true', 'false')]
|
||||
[string]$ExpectedIsAdministrator,
|
||||
[string]$BridgeDiscoveryDirectory = "$env:LOCALAPPDATA\Langsu\Lserp\AgentBridge",
|
||||
[string]$PreflightReportDirectory = "$env:LOCALAPPDATA\Langsu\Lserp\AcceptanceReports",
|
||||
[ValidatePattern('^[A-Za-z0-9_.-]{1,128}$')]
|
||||
[string]$CredentialTarget = 'Langsu.Lserp.AstrBot.ApiKey'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expectedAdministrator = $ExpectedIsAdministrator -ieq 'true'
|
||||
|
||||
if ($env:LSERP_ASTRBOT_API_KEY) {
|
||||
throw 'Commercial startup refuses LSERP_ASTRBOT_API_KEY in the process environment; use Windows Credential Manager.'
|
||||
}
|
||||
if ($env:MINIMAX_API_KEY) {
|
||||
throw 'MiniMax credentials must stay in the AstrBot service account, not in the desktop process environment.'
|
||||
}
|
||||
|
||||
foreach ($expectedScopeValue in @(
|
||||
$ExpectedUserId,
|
||||
$ExpectedUserName,
|
||||
$ExpectedAccountBook,
|
||||
$ExpectedSubSystemId)) {
|
||||
if ([string]::IsNullOrWhiteSpace($expectedScopeValue) -or
|
||||
$expectedScopeValue -cne $expectedScopeValue.Trim()) {
|
||||
throw 'Expected ERP session scope values must be nonblank and trimmed.'
|
||||
}
|
||||
foreach ($character in $expectedScopeValue.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) {
|
||||
throw 'Expected ERP session scope values must not contain control characters.'
|
||||
}
|
||||
}
|
||||
}
|
||||
$ExpectedDatabaseScopeFingerprint =
|
||||
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant()
|
||||
|
||||
function Get-ErpSessionScopeToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$DatabaseScopeFingerprint,
|
||||
[Parameter(Mandatory = $true)][string]$UserId,
|
||||
[Parameter(Mandatory = $true)][string]$UserName,
|
||||
[Parameter(Mandatory = $true)][string]$AccountBook,
|
||||
[Parameter(Mandatory = $true)][string]$SubSystemId,
|
||||
[Parameter(Mandatory = $true)][bool]$IsAdministrator
|
||||
)
|
||||
|
||||
$builder = New-Object Text.StringBuilder
|
||||
[void]$builder.Append("lserp-pet-session-scope-v3`n")
|
||||
$administratorText = if ($IsAdministrator) { 'true' } else { 'false' }
|
||||
foreach ($part in @(
|
||||
@('databaseScopeFingerprint', $DatabaseScopeFingerprint),
|
||||
@('userId', $UserId),
|
||||
@('userName', $UserName),
|
||||
@('accountBook', $AccountBook),
|
||||
@('subSystemId', $SubSystemId),
|
||||
@('isAdministrator', $administratorText))) {
|
||||
$byteCount = [Text.Encoding]::UTF8.GetByteCount([string]$part[1])
|
||||
[void]$builder.Append([string]$part[0])
|
||||
[void]$builder.Append('=')
|
||||
[void]$builder.Append($byteCount.ToString(
|
||||
[Globalization.CultureInfo]::InvariantCulture))
|
||||
[void]$builder.Append(':')
|
||||
[void]$builder.Append([string]$part[1])
|
||||
[void]$builder.Append("`n")
|
||||
}
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$digest = $algorithm.ComputeHash(
|
||||
[Text.Encoding]::UTF8.GetBytes($builder.ToString()))
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $algorithm) { $algorithm.Dispose() }
|
||||
}
|
||||
return -join @($digest[0..15] | ForEach-Object { $_.ToString('x2') })
|
||||
}
|
||||
|
||||
$expectedSessionScopeToken = Get-ErpSessionScopeToken `
|
||||
-DatabaseScopeFingerprint $ExpectedDatabaseScopeFingerprint `
|
||||
-UserId $ExpectedUserId `
|
||||
-UserName $ExpectedUserName `
|
||||
-AccountBook $ExpectedAccountBook `
|
||||
-SubSystemId $ExpectedSubSystemId `
|
||||
-IsAdministrator $expectedAdministrator
|
||||
|
||||
$uri = $null
|
||||
if (-not [Uri]::TryCreate($AstrBotBaseUrl, [UriKind]::Absolute, [ref]$uri)) {
|
||||
throw 'AstrBot base URL is invalid.'
|
||||
}
|
||||
if ($uri.Scheme -notin @('http', 'https') -or
|
||||
-not $uri.IsLoopback -or
|
||||
$uri.UserInfo -or $uri.Query -or $uri.Fragment) {
|
||||
throw 'Current commercial transport requires same-machine loopback AstrBot without URL credentials, query, or fragment; remote mode requires the future Agent Gateway.'
|
||||
}
|
||||
|
||||
$root = [IO.Path]::GetFullPath($PackageRoot)
|
||||
$packageArchiveFull = [IO.Path]::GetFullPath($PackageArchivePath)
|
||||
$hostPath = [IO.Path]::GetFullPath((Join-Path $root 'Host\Lskj.AgentPet.Host.exe'))
|
||||
$hostCriticalPaths = @(
|
||||
$hostPath,
|
||||
[IO.Path]::GetFullPath((Join-Path $root 'Host\Lskj.AgentPet.Host.dll')),
|
||||
[IO.Path]::GetFullPath((Join-Path $root 'Host\Lskj.AgentPet.Host.Core.dll')),
|
||||
[IO.Path]::GetFullPath((Join-Path $root 'Host\lserp-agent-cli.exe'))
|
||||
)
|
||||
$spriteFull = [IO.Path]::GetFullPath($SpritePath)
|
||||
$licenseFull = [IO.Path]::GetFullPath($SpriteLicenseEvidence)
|
||||
$astrBotComplianceFull = [IO.Path]::GetFullPath($AstrBotComplianceEvidence)
|
||||
$miniMaxComplianceFull = [IO.Path]::GetFullPath($MiniMaxServiceComplianceEvidence)
|
||||
$miniMaxProbeFull = [IO.Path]::GetFullPath($MiniMaxVisionProbeEvidence)
|
||||
$legacyArtifactFull = [IO.Path]::GetFullPath($LegacyArtifactRoot)
|
||||
$rolloutPolicyFull = [IO.Path]::GetFullPath($RolloutPolicyPath)
|
||||
foreach ($required in @($hostCriticalPaths + @(
|
||||
$packageArchiveFull,
|
||||
$spriteFull, $licenseFull, $astrBotComplianceFull,
|
||||
$miniMaxComplianceFull, $miniMaxProbeFull))) {
|
||||
if (-not [IO.File]::Exists($required)) {
|
||||
throw "Required file not found: $required"
|
||||
}
|
||||
$item = Get-Item -LiteralPath $required
|
||||
if ($item.Length -le 0 -or
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw "Required input must be a non-empty regular file: $required"
|
||||
}
|
||||
}
|
||||
if ([IO.Path]::GetExtension($packageArchiveFull) -cne '.zip' -or
|
||||
(Get-Item -LiteralPath $packageArchiveFull -Force).Length -gt 4GB) {
|
||||
throw 'PackageArchivePath must be the final commercial ZIP no larger than 4 GB.'
|
||||
}
|
||||
if (-not [IO.File]::Exists($rolloutPolicyFull)) {
|
||||
throw 'Command rollout policy file was not found.'
|
||||
}
|
||||
$rolloutPolicyItem = Get-Item -LiteralPath $rolloutPolicyFull -Force
|
||||
if ($rolloutPolicyItem.Length -le 0 -or
|
||||
$rolloutPolicyItem.Length -gt 256KB -or
|
||||
(($rolloutPolicyItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw 'Command rollout policy must be a non-empty regular file no larger than 256 KB.'
|
||||
}
|
||||
foreach ($evidence in @(
|
||||
$licenseFull, $astrBotComplianceFull, $miniMaxComplianceFull)) {
|
||||
$item = Get-Item -LiteralPath $evidence -Force
|
||||
$extension = [IO.Path]::GetExtension($evidence).ToLowerInvariant()
|
||||
if (@('.pdf', '.p7s') -notcontains $extension -or $item.Length -gt 16MB) {
|
||||
throw 'Commercial third-party compliance evidence must be a reviewed PDF or P7S file no larger than 16 MB.'
|
||||
}
|
||||
}
|
||||
if ([IO.Path]::GetExtension($miniMaxProbeFull) -cne '.json' -or
|
||||
(Get-Item -LiteralPath $miniMaxProbeFull -Force).Length -gt 64KB) {
|
||||
throw 'MiniMaxVisionProbeEvidence must be a JSON file no larger than 64 KB.'
|
||||
}
|
||||
|
||||
$expectedHostThumbprint = $HostCertificateThumbprint.ToUpperInvariant()
|
||||
foreach ($criticalPath in $hostCriticalPaths) {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $criticalPath
|
||||
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
|
||||
$null -eq $signature.SignerCertificate -or
|
||||
$signature.SignerCertificate.Thumbprint.ToUpperInvariant() -ne
|
||||
$expectedHostThumbprint) {
|
||||
throw 'Desktop host and bridge CLI first-party binaries must use the expected valid Authenticode certificate.'
|
||||
}
|
||||
}
|
||||
|
||||
$verifyPath = [IO.Path]::GetFullPath((Join-Path $root 'Verify-LserpCommercialPackage.ps1'))
|
||||
$windowsPowerShell = Join-Path $env:SystemRoot `
|
||||
'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
if (-not [IO.File]::Exists($verifyPath) -or
|
||||
-not [IO.File]::Exists($windowsPowerShell)) {
|
||||
throw 'Commercial preflight verifier or Windows PowerShell 5.1 is missing.'
|
||||
}
|
||||
$preflightErpProcess = Get-Process -Id $ErpProcessId -ErrorAction Stop
|
||||
$preflightErpStartedAtUtc =
|
||||
$preflightErpProcess.StartTime.ToUniversalTime()
|
||||
$preflightErpExecutable =
|
||||
[IO.Path]::GetFullPath($preflightErpProcess.MainModule.FileName)
|
||||
if ([IO.Path]::GetFileName($preflightErpExecutable) -cne 'Ls_ERP.exe') {
|
||||
throw 'ErpProcessId must identify the intended Ls_ERP.exe process.'
|
||||
}
|
||||
$preflightArguments = @(
|
||||
'-NoLogo', '-NoProfile', '-File', $verifyPath,
|
||||
'-PackageRoot', $root,
|
||||
'-PackageArchivePath', $packageArchiveFull,
|
||||
'-ExpectedPackageVersion', $ExpectedPackageVersion,
|
||||
'-SpritePath', $spriteFull,
|
||||
'-SpriteLicenseEvidence', $licenseFull,
|
||||
'-AstrBotComplianceEvidence', $astrBotComplianceFull,
|
||||
'-MiniMaxServiceComplianceEvidence', $miniMaxComplianceFull,
|
||||
'-MiniMaxVisionProbeEvidence', $miniMaxProbeFull,
|
||||
'-HostCertificateThumbprint', $expectedHostThumbprint,
|
||||
'-AstrBotBaseUrl', $uri.AbsoluteUri,
|
||||
'-CredentialTarget', $CredentialTarget,
|
||||
'-BridgeDiscoveryDirectory', $BridgeDiscoveryDirectory,
|
||||
'-LegacyArtifactRoot', $legacyArtifactFull,
|
||||
'-RolloutPolicyPath', $rolloutPolicyFull,
|
||||
'-RolloutCustomerId', $RolloutCustomerId,
|
||||
'-ErpProcessId', [string]$ErpProcessId,
|
||||
'-ExpectedDatabaseScopeFingerprint',
|
||||
$ExpectedDatabaseScopeFingerprint,
|
||||
'-ExpectedUserId', $ExpectedUserId,
|
||||
'-ExpectedUserName', $ExpectedUserName,
|
||||
'-ExpectedAccountBook', $ExpectedAccountBook,
|
||||
'-ExpectedSubSystemId', $ExpectedSubSystemId,
|
||||
'-ExpectedIsAdministrator', $(if ($expectedAdministrator) {
|
||||
'true'
|
||||
} else { 'false' }),
|
||||
'-ReportDirectory', $PreflightReportDirectory
|
||||
)
|
||||
& $windowsPowerShell @preflightArguments | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Commercial preflight failed; desktop host will not start.'
|
||||
}
|
||||
|
||||
$credentialListing = (& "$env:SystemRoot\System32\cmdkey.exe" "/list:$CredentialTarget" 2>$null | Out-String)
|
||||
if ($LASTEXITCODE -ne 0 -or $credentialListing.IndexOf(
|
||||
$CredentialTarget,
|
||||
[StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
||||
throw 'AstrBot credential is missing from Windows Credential Manager.'
|
||||
}
|
||||
|
||||
$discoveryRoot = [IO.Path]::GetFullPath($BridgeDiscoveryDirectory)
|
||||
if (-not [IO.Directory]::Exists($discoveryRoot)) {
|
||||
throw 'No running ERP AgentBridge discovery directory was found.'
|
||||
}
|
||||
$liveBridges = @()
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $discoveryRoot -Filter 'agentbridge-*.json' -File -ErrorAction SilentlyContinue)) {
|
||||
try {
|
||||
if ($file.Length -le 0 -or $file.Length -gt 65536 -or
|
||||
(($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
continue
|
||||
}
|
||||
$document = [IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json
|
||||
$pidText = [string]$document.processId
|
||||
$pidValue = 0
|
||||
$bridgeInstanceId = [string]$document.bridgeInstanceId
|
||||
$expectedPipeName = $null
|
||||
if (-not [int]::TryParse($pidText, [ref]$pidValue) -or $pidValue -le 0 -or
|
||||
$file.Name -ine "agentbridge-$pidValue.json" -or
|
||||
[string]$document.protocolVersion -cne '1.0' -or
|
||||
-not [Text.RegularExpressions.Regex]::IsMatch(
|
||||
$bridgeInstanceId, '^[a-f0-9]{32}$')) {
|
||||
continue
|
||||
}
|
||||
$expectedPipeName = "lserp.agent.$pidValue.$bridgeInstanceId"
|
||||
if ([string]$document.pipeName -cne $expectedPipeName) {
|
||||
continue
|
||||
}
|
||||
$startedAt = [DateTimeOffset]::MinValue
|
||||
$startedAtText = [string]$document.startedAtUtc
|
||||
if (-not [Text.RegularExpressions.Regex]::IsMatch(
|
||||
$startedAtText,
|
||||
'(?:Z|[+-][0-9]{2}:[0-9]{2})$') -or
|
||||
-not [DateTimeOffset]::TryParse(
|
||||
$startedAtText,
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
[Globalization.DateTimeStyles]::AssumeUniversal,
|
||||
[ref]$startedAt)) {
|
||||
continue
|
||||
}
|
||||
$process = Get-Process -Id $pidValue -ErrorAction Stop
|
||||
$actualStart = [DateTimeOffset]($process.StartTime.ToUniversalTime())
|
||||
if ([Math]::Abs(($actualStart - $startedAt.ToUniversalTime()).TotalSeconds) -gt 1) {
|
||||
continue
|
||||
}
|
||||
$liveBridges += [PSCustomObject]@{
|
||||
ProcessId = $pidValue
|
||||
PipeName = [string]$document.pipeName
|
||||
BridgeInstanceId = $bridgeInstanceId
|
||||
StartedAtUnixSeconds = $startedAt.ToUniversalTime().ToUnixTimeSeconds()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
$selected = @($liveBridges | Where-Object {
|
||||
$_.ProcessId -eq $ErpProcessId
|
||||
})
|
||||
if ($selected.Count -ne 1) {
|
||||
throw 'The specified ERP process is not running a valid AgentBridge.'
|
||||
}
|
||||
$boundProcessId = [int]$selected[0].ProcessId
|
||||
$boundStartedAt = [long]$selected[0].StartedAtUnixSeconds
|
||||
$boundProcess = Get-Process -Id $boundProcessId -ErrorAction Stop
|
||||
$boundExecutable = [IO.Path]::GetFullPath(
|
||||
$boundProcess.MainModule.FileName)
|
||||
if ($boundExecutable -cne $preflightErpExecutable -or
|
||||
[Math]::Abs((
|
||||
$boundProcess.StartTime.ToUniversalTime() -
|
||||
$preflightErpStartedAtUtc).TotalSeconds) -gt 1) {
|
||||
throw 'The ERP process changed during commercial startup verification.'
|
||||
}
|
||||
$boundSessionId = "lserp-pet-p$boundProcessId-s$boundStartedAt-c$expectedSessionScopeToken-$([Guid]::NewGuid().ToString('N'))"
|
||||
|
||||
$env:LSERP_ASTRBOT_BASE_URL = $uri.AbsoluteUri.TrimEnd('/')
|
||||
$env:LSERP_ASTRBOT_SESSION_ID = $boundSessionId
|
||||
$env:LSERP_AGENT_BRIDGE_PROCESS_ID = [string]$boundProcessId
|
||||
$env:LSERP_AGENT_EXPECTED_DATABASE_SCOPE_FINGERPRINT =
|
||||
$ExpectedDatabaseScopeFingerprint
|
||||
$env:LSERP_AGENT_EXPECTED_USER_ID = $ExpectedUserId
|
||||
$env:LSERP_AGENT_EXPECTED_USER_NAME = $ExpectedUserName
|
||||
$env:LSERP_AGENT_EXPECTED_ACCOUNT_BOOK = $ExpectedAccountBook
|
||||
$env:LSERP_AGENT_EXPECTED_SUBSYSTEM_ID = $ExpectedSubSystemId
|
||||
$env:LSERP_AGENT_EXPECTED_IS_ADMINISTRATOR = if ($expectedAdministrator) {
|
||||
'true'
|
||||
} else { 'false' }
|
||||
$env:LSERP_AGENT_EXPECTED_SESSION_SCOPE_TOKEN = $expectedSessionScopeToken
|
||||
$env:LSERP_AGENT_BRIDGE_DISCOVERY = $discoveryRoot
|
||||
$env:LSERP_ASTRBOT_SPRITE_PATH = $spriteFull
|
||||
$env:LSERP_PET_SPRITE_PATH = $spriteFull
|
||||
$env:LSERP_ASTRBOT_CREDENTIAL_TARGET = $CredentialTarget
|
||||
Start-Process -FilePath $hostPath -WorkingDirectory ([IO.Path]::GetDirectoryName($hostPath))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
# 动态低代码模块新增与并发修改验收
|
||||
|
||||
## 事实边界
|
||||
|
||||
通用模块不在 C# 中写死客户字段。每次计划和确认后执行都必须从当前已登录 ERP 进程的实际数据库重新取得:
|
||||
|
||||
- 用户、账套、子系统、数据库作用域指纹和原 ERP 菜单编辑权限;
|
||||
- 模块类型、主表/明细字段、类型、必填、只读、默认值和 Lookup 配置;
|
||||
- 完整低代码配置指纹。
|
||||
|
||||
模型只能传当前合同发布的不透明 `parameterId` 和可编辑业务值。数据库口令、连接串、表/物理字段、SQL、过程名、账套/用户字段、自动编号、审批状态、服务端默认值不是模型参数。固定的只有命令名、参数包结构、确认、幂等、事务、审计和验收证据协议。
|
||||
|
||||
菜单编辑权限和模块操作开关必须同时成立。基础档案配置中的 `addEnable` 不是 `1/true` 时不得发布或执行新增,`modifyEnable` 不是 `1/true` 时不得解析或执行修改;字段缺失也按关闭处理。单据旧配置若没有同名开关,仍须通过菜单权限、完整字段合同和独立签名事务适配器。计划与确认后执行都会重新读取这些配置,不能用旧合同绕过后来关闭的按钮。
|
||||
|
||||
当前通用写动作发布 `module.record.create` `1.0`,以及仅限基础档案标量字段的 `module.record.resolve-update` → `module.record.update` `1.0`。修改动作必须先由固定只读过程唯一定位记录并取得完整可编辑字段快照、版本令牌和快照指纹;确认执行时在事务锁内重新比较,发生并发变化必须返回冲突,绝不静默覆盖。单据明细修改、Lookup 字段修改和通用提交仍需要各自的适配器/状态机合同,在完成同等门禁前不得伪装成可用。
|
||||
|
||||
## 数据库契约
|
||||
|
||||
按顺序部署 `SqlServer/001_agent_business_idempotency.sql`、`SqlServer/004_dynamic_module_adapter_contract.sql` 和 `SqlServer/005_dynamic_module_update_contract.sql`。`004`/`005` 只创建空证据表、就绪查询和拒绝服务占位过程;不会写客户业务表。
|
||||
|
||||
当前动态新增/更新使用 v2 数据库对象和 `schema_version=2.0`。v2 与旧 v1 证据表/过程并存,不原地修改旧对象;旧客户可先部署并验收 v2,再重启桥切换。桥不接受 v1 就绪结果,避免把缺少原生保存族绑定的旧证据当成新验收。
|
||||
|
||||
客户适配层必须实现两个固定入口:
|
||||
|
||||
- `dbo.p_lserp_agent_module_lookup_read_v1`:只读、唯一候选或停止,不得有副作用。
|
||||
- `dbo.p_lserp_agent_module_create_v2`:参与调用方 `Serializable` 事务,重读当前配置、重做权限/原生校验/默认值/编号/模块钩子,并在同一事务内完成持久幂等与业务审计。
|
||||
|
||||
代码审计确认,ERP 现有原生保存族本身也是按配置路由:基础档案由 `BaseModuleImpl.SaveBasePanelData(BaseSaveModel)` 进入 `P_BaseSave`/`p_BaseSave70`,单据由 `BillImpl.BillSave(...)` 进入 `P_BillSavePr_3`/`P_BillSavePr70`,基础档案提交进入 `p_baseApply`。客户适配器应复用并验收对应原生路径及其前后钩子,不能改成绕开业务规则的直接表写入;但旧界面会先拼装 SQL 文本,因此不能把模型输入直接交给这些旧入口,必须由当前低代码配置重新映射字段并在固定事务边界内构造。
|
||||
|
||||
`module.parameters` 会在 `nativeExecutionProfiles` 中输出不含表名、字段名和 SQL 的验收投影:`nativeSaveFamily` 与 `profileFingerprint`。签发新增或更新清单时,模块列表必须同时填写这两个值;数据库 readiness、最终事务过程、Windows TrustedPeople 签名清单和确认后的重新计划会四次核对它们。`NewVer`、模块类型、动作或完整配置任一变化都会使旧原生执行指纹失效,必须重新验收,不能把 `P_BaseSave` 的验收扩大解释为 `p_BaseSave70`,也不能把基础档案验收扩大到单据保存。
|
||||
|
||||
基础档案修改还必须实现三个固定入口:
|
||||
|
||||
- `dbo.p_lserp_agent_module_update_snapshot_v2`:只读且必须唯一定位,不允许“取第一条”;返回私有记录定位值、版本令牌、快照指纹以及全部可编辑标量值。
|
||||
- `dbo.p_lserp_agent_module_update_readiness_v2`:返回当前数据库、基础档案和精确配置对应的更新专用验收证据。
|
||||
- `dbo.p_lserp_agent_module_update_v2`:参与调用方 `Serializable` 事务,先检查持久幂等,再以 `UPDLOCK/HOLDLOCK` 锁定唯一记录,重算版本/快照、权限、配置和原生校验,执行部分字段修改、模块钩子和审计;冲突时不得写入。
|
||||
|
||||
过程不得接受模型选择的表名、字段名、SQL 或过程名,不得在内部提交/回滚调用方外层事务。详细参数、XML 和结果列契约以 `004`/`005` 的占位过程及注释为准。
|
||||
|
||||
## Windows 签发
|
||||
|
||||
1. 在可恢复、明确非生产的 UAT 数据库中,逐模块验证成功、回滚、重放、幂等冲突、配置漂移、权限漂移、默认值/编号/钩子和审计证据。
|
||||
2. 从同一登录会话的 `module.parameters` 取得精确 `moduleCode` 和 `configurationFingerprint`;`document` 在清单中写 `bill`,`master_data` 写 `base`。复制 `dynamic-module-write-modules.example.json` 到包外 ACL 受控目录,仅列出已验收模块。
|
||||
3. 在 Windows PowerShell 5.1 中运行 `New-DynamicModuleWriteAcceptance.ps1`。九个验收开关都必须显式传入;证书必须位于 `CurrentUser` 或 `LocalMachine\TrustedPeople`、当前有效并拥有 RSA CSP 私钥。输出用 `CreateNew` 语义,不覆盖已有清单。
|
||||
4. 由客户 DBA/发布流水线对每个清单模块调用 `dbo.p_lserp_agent_module_write_acceptance_v2`,使用签发输出的精确 `evidenceSha256` 和 `validatedAtUtc`。不得把该过程或证据表写权授予 ERP 日常账号。
|
||||
|
||||
修改能力必须单独验收,不能沿用新增清单:复制 `dynamic-module-update-modules.example.json`,只列 `base` 模块;验证唯一记录解析、完整快照绑定、事务锁、版本冲突、部分字段修改、权限/配置漂移、重放与幂等冲突后,在 Windows PowerShell 5.1 运行 `New-DynamicModuleUpdateAcceptance.ps1`,显式提供全部十二个验收开关。随后由 DBA 调用 `dbo.p_lserp_agent_module_update_acceptance_v2` 写入与签名清单完全一致的证据。
|
||||
|
||||
签发命令的完整开关与参数可用以下命令查看:
|
||||
|
||||
```powershell
|
||||
Get-Help .\New-DynamicModuleWriteAcceptance.ps1 -Full
|
||||
Get-Help .\New-DynamicModuleUpdateAcceptance.ps1 -Full
|
||||
```
|
||||
|
||||
## 运行时启用
|
||||
|
||||
在启动 ERP 前由受控启动器注入:
|
||||
|
||||
```text
|
||||
LSERP_DYNAMIC_MODULE_LOOKUP_ENABLED=1
|
||||
LSERP_DYNAMIC_MODULE_LOOKUP_READINESS_SHA256=<Lookup 只读验收摘要>
|
||||
LSERP_DYNAMIC_MODULE_WRITE_ENABLED=1
|
||||
LSERP_DYNAMIC_MODULE_WRITE_READINESS_SHA256=<签名清单 contentSha256>
|
||||
LSERP_DYNAMIC_MODULE_WRITE_ACCEPTANCE_PATH=<包外 ACL 受控的签名清单路径>
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_ENABLED=1
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_READINESS_SHA256=<更新专用签名清单 contentSha256>
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_ACCEPTANCE_PATH=<包外 ACL 受控的更新专用签名清单路径>
|
||||
```
|
||||
|
||||
发布策略还必须以精确版本/权限放行实际已注册的 `module.parameters` (`module.view`)、`module.record.prepare-create` (`module.view`)、可选 `module.record.resolve-create` (`module.view`)、`module.record.create` (`module.edit`),以及可选 `module.record.resolve-update` (`module.view`) 和 `module.record.update` (`module.edit`)。发布文件引用未注册命令会阻止桥发布,所以必须与当次启用开关一致。
|
||||
|
||||
重启后先读 `capabilities.list` 和 `module.parameters`。只有当前模块同时命中当前数据库证据行、当前配置指纹和 TrustedPeople 签名清单时,合同才返回 `genericWriteExecutionAvailable=true` 与 `writeCommand=module.record.create`。基础档案还必须单独命中更新证据和更新签名清单,才返回 `genericUpdateExecutionAvailable=true` 与 `updateCommand=module.record.resolve-update`;否则只允许参数发现/预演。
|
||||
|
||||
新增时的桌宠确认必须以结构化卡片完整显示本次主表和每行明细的不透明参数 ID、业务标签、类型与值。修改时必须显示唯一记录的业务描述,以及每个字段的旧值和新值;记录定位值、版本令牌、物理字段和表名都不得进入模型或预览。两种写操作都必须滚动核对完整内容后才启用确认按钮;不能把嵌套参数退化成一段 JSON。删除字段、增加未知/物理字段、重复参数 ID、跳号明细、加入控制字符、超过值/总量上限、篡改 `data.preview`/`parameterPreview` 或适配器摘要时,宿主和桌宠都必须失败关闭且不弹 ERP 原生确认。确认后任一权限、配置、合同、Lookup/记录快照凭证或验收证据变化都要失败并重新预览;数据库版本冲突必须重新读取后再次确认。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 客户现场放行路线图
|
||||
|
||||
每次桥调用都必须携带由核准 ERP 会话范围计算的 v3 `sessionScopeToken`;服务端权威复核缺失、格式错误或会话漂移,不能只依赖页面或 CLI 的本地检查。
|
||||
|
||||
本文是实施人员使用的短版路线图。详细字段、命令参数、证据结构和负例要求以 `CUSTOMER_ACCEPTANCE.md`、`WRITE_ACCEPTANCE.md` 为准。任何阶段未完成时,相关写命令必须保持未注册;不得用修改配置、跳过预检或手工拼接证据的方式放行。
|
||||
|
||||
## 阶段 0:源码与第三方前置条件
|
||||
|
||||
- [ ] 已轮换所有曾进入聊天、日志、截图或临时文件的 API Key、数据库口令和机器人凭据。
|
||||
- [ ] 待发布源码来自已审查、无未提交文件的固定提交;桌宠包和旧 ERP 构建绑定同一 40 位提交号。
|
||||
- [ ] 已复核 `guga-upstream-audit.v1.json`,确认没有把 npm/网站 MIT 误当成素材许可;禁止生产机直接运行 `npx codex-pets add guga`,并已取得绑定实际精灵图 SHA-256、权利人、商业使用、客户部署、复制、展示、地域和期限的书面商用许可;同时完成 AstrBot AGPL/EULA 与 MiniMax API 服务条款、区域、计费和数据处理审查。
|
||||
- [ ] 客户已批准发布证书、RFC3161 时间戳服务、制品库、SBOM/恶意软件扫描和回滚负责人。
|
||||
|
||||
未满足以上任一项:只允许开发验证,不进入客户商用预检。
|
||||
|
||||
## 阶段 1:Windows 签名构建
|
||||
|
||||
- [ ] 在客户 Windows 构建机从固定干净提交运行 `Build-LegacyErpAcceptance.ps1`,生成独立的 `LegacyArtifactRoot`。
|
||||
- [ ] 构建脚本在签名前完成最终 `Ls_ERP.exe`、`lserp-cli.exe` 和顶层 `Lskj.*.dll` 凭据扫描;未出现 `legacy_runtime_hardcoded_sql_credential:<文件名>`,且扫描失败时日志不包含用户名或口令值。
|
||||
- [ ] `Ls_ERP.exe`、`lserp-cli.exe` 及桥/内核依赖通过 .NET Framework 4、x86、DevExpress 15.2、CEF 和 Authenticode 检查。
|
||||
- [ ] 使用同一客户发布证书签署桌宠 Host 的 EXE 与两个业务 DLL;保存签名后哈希。
|
||||
- [ ] `Verify-LserpCommercialPackage.ps1` 对最终 ZIP、解包清单、WebView2、AstrBot、MiniMax 探针、素材授权、发布策略和精确 ERP PID 全部通过。
|
||||
|
||||
未签名、提交号不一致、二进制在取证后被修改、存在多个 ERP 实例但未绑定 PID:立即停止。
|
||||
|
||||
## 阶段 2:客户只读画像与配置映射
|
||||
|
||||
- [ ] 仅使用客户批准的只读账号和只读脚本核对表、字段、过程、单据状态、权限和低代码配置;口令只从安全标准输入或客户凭据系统读取。
|
||||
- [ ] 对尚未允许正常 ERP 登录的零写入目标库,先在 Windows PowerShell 5.1 交互执行 `$credential = Get-Credential -UserName <SQL只读账号>`,再运行 `Invoke-LserpSelectOnlyCatalogSnapshot.ps1 -Server <受信任TLS端点> -Database <库名> -Credential $credential -OutputPath <新snapshot.json>`。脚本强制 `Encrypt=true`、系统证书验证、`ApplicationIntent=ReadOnly`,并在目录读取前后各检查一次数据库/服务器角色、数据库级 DML/DDL/EXECUTE、所有用户表/视图、过程/函数和 Schema 的有效写权限;任一次发现可写即返回 `database_principal_not_select_only`,不会发布快照或降级继续。
|
||||
- [ ] SELECT-only 快照只执行两段随包固定且已通过 TSql100 AST 校验的 `sys.*` 查询;不执行存储过程、不读业务行。采集器还把 PowerShell 实际加载的脚本 AST 字节与连接前、读取后的磁盘脚本 SHA-256 逐字绑定,脚本运行中被替换即返回 `tool_source_changed`。输出采用当前用户与 LocalSystem ACL、`CreateNew` 语义,只包含目标身份哈希、兼容级别、对象计数和最多 100000 个目录成员哈希,不包含服务器名、数据库名、登录名、密码或物理对象名。它用于零写入初始目录证明,不能代替后续绑定真实 ERP 用户/账套/子系统的桥会话预检。
|
||||
- [ ] 立即用最终 CLI 运行 `lserp-cli adapters verify-catalog-snapshot --input <snapshot.json> --profile <customer-profile.json> --tool-sha256 <随包采集脚本SHA-256>`;只接受 `schemaVersion=1.1`。只有工具字节稳定、24 小时时效、权限前后双检、TLS/只读声明、集合哈希、数据库身份/计数和画像关键目录全部匹配时,`onlineMetadataMatches=true`。验证完全离线且结果固定 `registrationReady=false`;缺失项只以 SHA-256 表示,不能据此自动修改画像或启用写命令。
|
||||
- [ ] 现场优先使用 `Invoke-LserpSelectOnlyProfilePreflight.ps1` 一次完成上述两步。它要求显式固定画像、采集器、最终签名 CLI 的 SHA-256 和 CLI 签发者,只通过 `PSCredential` 对象把口令交给采集器;随后锁定三份输入、调用离线验证器,并以受限 ACL/`CreateNew` 分别发布快照和脱敏报告。报告不含服务器、库、主体、对象名或路径;目录漂移会保留 `passed=false` 报告并返回非零,其他协议/身份故障不会留下未验证快照。该包装器仍固定 `registrationReady=false`,不会登录旧 ERP、修改画像或开启业务写入。
|
||||
- [ ] 先由实施人员在客户机本地核对 ERP 已登录界面,并从批准的 ERP 启动记录/只读会话交接中取得本次会话的 PID、数据库作用域指纹、用户编号、用户名、账套、子系统和管理员属性;不得猜测或让模型生成这些值。桥/工作流命令(包括独立签名管理员 `lserp-cli.exe`)不接受只传 PID 的引导调用。需要用 CLI 复核时,必须把七项范围完整展开,例如 `lserp-cli.exe bridge context --erp-process-id <PID> --expected-database-scope-fingerprint <64位小写SHA-256> --expected-user-id <用户编号> --expected-user-name <用户名> --expected-account-book <账套> --expected-subsystem-id <子系统编号> --expected-is-administrator <true|false>`;该调用只验证已批准的预期范围与当前 ERP 上下文逐项一致,不能反过来作为“先连接再猜范围”的来源。不得把原始响应发给模型或写入普通日志。最终 ZIP 中的受限 `Host/lserp-agent-cli.exe` 从第一次调用起同样要求完整七项范围。将 `field-readonly-validation.example.json` 复制到包外 ACL 受控的新文件,逐项填写运行时 CLI 版本/SHA-256/签发者、ERP/预检脚本 SHA-256、PID、上述预期会话、发布策略客户 ID/SHA-256,以及 1–16 个 `purchase/leave/diagnosis/support → 客户实际模块或导航编号` 绑定;模板固定 `approved=false` 和零哈希,禁止直接使用。审批人核对后把 `approved=true`,审批窗口不得超过 24 小时,且 `databaseCredentialsIncluded` 必须保持 `false`。管理员 `lserp-cli.exe` 仅用于本地复核、离线配置与验收,不得填入运行时 CLI 身份字段。
|
||||
- [ ] 对审核后的原始输入文件计算 SHA-256,先运行 `Invoke-LserpFieldReadOnlyValidation.ps1 -InputPath <受控输入.json> -ExpectedInputSha256 <原始文件SHA256> -OutputPath <新交接报告.json> -ValidateInputOnly`。通过后换一个新的交接报告路径、去掉 `-ValidateInputOnly` 执行;包装器会同时锁定输入和 `Invoke-LserpReadOnlySessionPreflight.ps1`,逐项传递 PID、CLI/ERP 身份、管理员属性、用户、账套、子系统、数据库指纹、发布策略、动态模块集合、三个工作流开关和超时。它不会从 Bridge 自动回填或信任这些审批值,也不接收数据库地址、用户名、密码或连接串。不同 ERP 用户/PID/账套必须使用不同审批文件,不能把普通采购/请假会话和管理员诊断会话拼成一个虚假范围。
|
||||
- [ ] 从最终桌宠核对标题下方只显示账套、子系统、用户和数据库指纹前 12 位证据,不显示服务器、库名、连接串或完整指纹。生成只读或可执行预览后切换数据库、用户、账套和子系统,确认旧计划在执行请求前作废;恢复原范围仍必须重新规划。数据库和登录身份不得作为模型业务参数传入。
|
||||
- [ ] 命名管道格式必须固定为 `lserp.agent.<PID>.<bridgeInstanceId>`。在同一 ERP PID 内重建命令桥,确认新发现文件的 `bridgeInstanceId` 变化,管道名也从 `lserp.agent.<PID>.<旧bridgeInstanceId>` 变为 `lserp.agent.<PID>.<新bridgeInstanceId>`;保留旧发现文件覆盖新桥监听前后的竞态窗口,旧客户端不得触达新桥。旧桌宠 Host/AstrBot 会话必须返回 `erp_bridge_instance_changed` 且不能发送业务请求,重新运行启动器后新会话才恢复。该标识不得进入模型、业务预览或交接报告。
|
||||
- [ ] 只读会话预检只能调用 `lserp-agent-cli version`、`bridge health/context/capabilities` 和 `bridge plan module.parameters`;每个桥子进程参数白名单必须同时包含精确 PID、数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值。受限 CLI 自身要在目标命令前后复核上下文,包装器还要独立比对审批范围,二者任一发现漂移都丢弃结果。1.5 报告必须包含 `cli_runtime_identity`、`rollout_database_scope`、`expected_session_scope` 与 `dynamic_module_execution_contracts`,证明 `bridgeOnly=true`、`databaseDirectAccess=false`,并显示 `commandExecuteInvoked=false`、`directDatabaseConnectionUsed=false`、`businessWriteAttempted=false`。1.1 交接报告必须绑定同一运行时 CLI 版本、SHA-256 和签发者。报告只能保存用户/账套/子系统哈希、动态合同/配置/原生执行指纹、经审核的保存族、参数类型与可编辑/必填数量、载荷策略及新增/修改阻断状态;载荷数量必须与参数合同一致,不能保存标签、参数 ID、业务值或物理配置。第一次参数发现使用 `validationStage=discovery`,可不要求尚未注册的业务工作流;适配器与发布策略完成后使用 `validationStage=final`,每个模块业务角色必须与对应工作流开关严格一致。诊断开关要求审批值和当前上下文都为内置管理员。
|
||||
- [ ] 不运行写 SQL,不启用草案中的写开关,不把真实业务值、连接串、原始异常或原始 SQL 放入聊天、模型上下文和交付证据。
|
||||
- [ ] 配置人员逐项复核 `customer-profiles` 候选映射;最终画像、`business-adapters.json` 和 `command-rollout.json` 放在包外受 ACL 保护的客户目录,并绑定原始字节 SHA-256。
|
||||
- [ ] 在任何在线激活操作前运行 `lserp-cli adapters activation-checklist --input <客户画像.json>`。当前示例画像必须以退出码 6 列出固定 5 个采购、4 个请假开放阻断项,且输出只含稳定代码、所需证据类型和固定下一步;不得含数据库名、物理字段、画像证据正文或 SQL。全部阻断关闭后退出码才为 0,但 `activationAllowed/registrationReady` 仍必须为 `false`,继续走签名验收和 V2/V3 运行时复核。
|
||||
- [ ] 只读探针证明实际客户库兼容性;任何字段、类型、过程、账套、子系统、用户权限或模块代码漂移都先回到配置评审。
|
||||
|
||||
输入/预检脚本哈希、审批窗口、预期数据库指纹、用户、账套、子系统或管理员属性任一不一致,ERP 会话在预检中切换、只读计划出现 `executionAllowed=true`、发现意外数据变化、权限超出只读范围、脱敏失败或映射歧义:立即停止并保全审计记录。
|
||||
|
||||
## 阶段 3:可恢复非生产写工作流 UAT
|
||||
|
||||
- [ ] 客户书面确认该库是可恢复的非生产 UAT,备份与恢复演练、ERP 原生确认、事务和审计查询均已验证。
|
||||
- [ ] 运行 `New-WorkflowUatAuthorization.ps1` 签发最长 24 小时、绑定客户/环境/ERP/CLI/包/画像/策略/用例的短期授权;令牌库始终留在受限目录,绝不进入交付包、聊天或模型上下文。
|
||||
- [ ] 运行 `New-WorkflowWriteUatCampaign.ps1` 建立固定活动;管理员 `lserp-cli.exe` 仅用于离线验证,最终包内 `lserp-agent-cli.exe` 仅用于版本与桥调用,两者路径、哈希和签发者分别锁定;每次开始或恢复先运行 `Test-WorkflowWriteUatCampaign.ps1`。
|
||||
- [ ] 严格按唯一 `nextCase` 逐项运行 `Invoke-WorkflowWriteCaseCapture.ps1`:采购 13 项、请假 19 项,一次只执行一个用例,禁止循环或批量写入;每项原始索引都必须绑定实际运行 CLI 的版本、SHA-256 和签发者。
|
||||
- [ ] 每项都由 DBA 只读核对业务变化数、事务、幂等、权限重检、原生确认、命令审计及来源附件摘要;确认后三项漂移场景必须保留人工暂停阶段。
|
||||
- [ ] 采购确认页完整展示单据、来源、金额和每一条明细,滚动到底后才可确认;请假创建完整展示员工、类型、流转类别、起止时间、工时、原因和“创建后提交=false”,提交审批另行确认。
|
||||
|
||||
授权过期或串用、ERP/CLI/包/画像/策略哈希漂移、一次触发多个用例、无法证明回滚或审计:立即停止。先恢复环境并重新签发活动,不复用旧令牌或证据。
|
||||
|
||||
## 阶段 4:低代码初始化诊断取证
|
||||
|
||||
- [ ] 分别验证:静态配置错误、初始化 SQL 失败、初始化无 SQL 失败、初始化超时/卡死风险四类场景。
|
||||
- [ ] 诊断确认页必须显示模块代码、导航代码、模块名、是否已打开、是否支持跟踪、跟踪范围、是否支持强制终止、最大事件数和最大时长。
|
||||
- [ ] 现场明确看到“20 秒只限制 SQL 证据采集窗口,旧版 UI 初始化无法安全强制终止”的风险提示;可能卡死的模块只在隔离测试环境复现。
|
||||
- [ ] 证据只保留会话别名、阶段、分类和调用位置,不保留参数值、原始 SQL、原始异常或客户数据。
|
||||
|
||||
范围、限制或风险说明不完整,页面声称能强制终止旧模块,或者诊断触及非目标 ERP 实例:禁止执行。
|
||||
|
||||
## 阶段 5:总验收与放行
|
||||
|
||||
- [ ] 按 `CUSTOMER_ACCEPTANCE.md` 收集 23 个原始制品;采购、请假、诊断分别保留自己的 ERP PID/用户/子系统 1.4 预检和绑定其 SHA-256 的现场交接,采购与请假各自使用单工作流 UAT 授权,诊断必须为管理员。不得合并三份会话证据,也不包含令牌库、审核输入原文、原始发票、请假原因、数据库口令或连接串。
|
||||
- [ ] 使用 `New-CustomerAcceptanceBundle.ps1` 生成客户 RSA-SHA256 总签章,并由最终签名 `lserp-cli acceptance verify-customer-bundle` 在隔离机复验。
|
||||
- [ ] 客户业务、DBA、安全、实施和发布负责人共同签收;发布后仍保持默认拒绝、最小权限、限流、熔断、审计和可回滚。
|
||||
- [ ] 记录最终 ZIP SHA-256、旧 ERP/CLI 哈希、签发者指纹、客户/环境标识、发布时间和回滚版本;不得记录秘密值。
|
||||
|
||||
缺少任一原始制品、总签章验证失败、制品过期或哈希不一致:不得上线。
|
||||
|
||||
## 全程停止条件
|
||||
|
||||
出现以下任一情况,无论处于哪个阶段都立即停止并转人工评审:
|
||||
|
||||
- 发现秘密泄漏、原始客户数据进入模型/日志/证据,或只读阶段产生任何写入。
|
||||
- 目标客户、账套、子系统、ERP 用户、模块、ERP PID、提交号或文件哈希不一致。
|
||||
- 使用未签名/签名失效二进制、旧候选包、脏源码构建或未经客户批准的第三方组件。
|
||||
- 无法证明一次确认只对应一个计划、一次业务动作、一个事务和一条完整审计链。
|
||||
- 现场状态与预览不一致、命令返回未知结果、桥或 ERP 重启、授权过期、恢复点不可用。
|
||||
@@ -0,0 +1,839 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$InputPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedInputSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
|
||||
[string]$PreflightScriptPath = '',
|
||||
|
||||
[switch]$ValidateInputOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$strictUtf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$inputLock = $null
|
||||
$preflightLock = $null
|
||||
$outputFull = $null
|
||||
$published = $false
|
||||
$executionAttempted = $false
|
||||
$safeSha256 = '^[0-9a-f]{64}$'
|
||||
$safeCliVersion = '^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$'
|
||||
$safeModuleCode = '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$'
|
||||
|
||||
function Throw-FieldValidationError([string]$Code) {
|
||||
throw ('lserp_field_readonly_validation_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($actual.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($actual -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-JsonArray([object]$Value) {
|
||||
return $null -ne $Value -and $Value -is [array]
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RegularFile(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
|
||||
(($item.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain `
|
||||
([IO.Path]::GetDirectoryName($full)) $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
||||
[IO.File]::Exists($full) -or
|
||||
[IO.Directory]::Exists($full)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
$directory = [IO.Path]::GetDirectoryName($full)
|
||||
Assert-NoReparseDirectoryChain $directory $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString(
|
||||
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-StreamSha256([IO.Stream]$Stream) {
|
||||
$position = $Stream.Position
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Stream.Position = 0
|
||||
return ([BitConverter]::ToString(
|
||||
$sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$Stream.Position = $position
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ScopedValueSha256([string]$Scope, [string]$Value) {
|
||||
return Get-Sha256Hex ($strictUtf8.GetBytes(
|
||||
'lserp-field-readonly-validation-v1|' + $Scope + '|' + $Value))
|
||||
}
|
||||
|
||||
function Read-LockedUtf8Bytes(
|
||||
[IO.FileStream]$Stream,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
if ($Stream.Length -le 0 -or $Stream.Length -gt $MaximumBytes -or
|
||||
$Stream.Length -gt [int]::MaxValue) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
$bytes = New-Object byte[] ([int]$Stream.Length)
|
||||
$Stream.Position = 0
|
||||
$offset = 0
|
||||
while ($offset -lt $bytes.Length) {
|
||||
$read = $Stream.Read($bytes, $offset, $bytes.Length - $offset)
|
||||
if ($read -le 0) { Throw-FieldValidationError $Code }
|
||||
$offset += $read
|
||||
}
|
||||
if ($bytes.Length -ge 3 -and
|
||||
$bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and
|
||||
$bytes[2] -eq 0xBF) {
|
||||
Throw-FieldValidationError ($Code + '_utf8_bom_forbidden')
|
||||
}
|
||||
return $bytes
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-StrictJsonText([string]$Text) {
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
$jsonReader.SupportMultipleContent = $true
|
||||
$stack = New-Object Collections.Stack
|
||||
$rootValues = 0
|
||||
try {
|
||||
while ($jsonReader.Read()) {
|
||||
$token = [string]$jsonReader.TokenType
|
||||
if ($token -eq 'Comment') {
|
||||
Throw-FieldValidationError 'input_json_comment_forbidden'
|
||||
}
|
||||
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
$names = if ($token -eq 'StartObject') {
|
||||
New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
} else { $null }
|
||||
$stack.Push([pscustomobject]@{
|
||||
Kind = if ($token -eq 'StartObject') {
|
||||
'object'
|
||||
} else { 'array' }
|
||||
Names = $names
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
|
||||
if ($stack.Count -eq 0) {
|
||||
Throw-FieldValidationError 'input_json_nesting_invalid'
|
||||
}
|
||||
$expectedKind = if ($token -eq 'EndObject') {
|
||||
'object'
|
||||
} else { 'array' }
|
||||
if ([string]$stack.Peek().Kind -cne $expectedKind) {
|
||||
Throw-FieldValidationError 'input_json_nesting_invalid'
|
||||
}
|
||||
[void]$stack.Pop()
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'PropertyName') {
|
||||
if ($stack.Count -eq 0 -or
|
||||
[string]$stack.Peek().Kind -cne 'object' -or
|
||||
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
|
||||
Throw-FieldValidationError `
|
||||
'input_json_duplicate_property'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
}
|
||||
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
|
||||
Throw-FieldValidationError 'input_json_root_invalid'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError 'input_json_invalid'
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
|
||||
$insideString = $false
|
||||
$escaped = $false
|
||||
for ($index = 0; $index -lt $Text.Length; $index++) {
|
||||
$character = $Text[$index]
|
||||
if ($insideString) {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
if ($character -eq '"') { $insideString = $false }
|
||||
continue
|
||||
}
|
||||
if ($character -eq '"') { $insideString = $true; continue }
|
||||
if ($character -ne ',') { continue }
|
||||
$next = $index + 1
|
||||
while ($next -lt $Text.Length -and
|
||||
[char]::IsWhiteSpace($Text[$next])) { $next++ }
|
||||
if ($next -lt $Text.Length -and
|
||||
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
|
||||
Throw-FieldValidationError 'input_json_trailing_comma'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-StrictRootObjectToken([string]$Text) {
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
try {
|
||||
$token = [Newtonsoft.Json.Linq.JToken]::ReadFrom($jsonReader)
|
||||
if ($token -isnot [Newtonsoft.Json.Linq.JObject]) {
|
||||
Throw-FieldValidationError 'input_json_root_invalid'
|
||||
}
|
||||
Write-Output -NoEnumerate $token
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_field_readonly_validation_failed:')) { throw }
|
||||
Throw-FieldValidationError 'input_json_invalid'
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-RawTopLevelString(
|
||||
[Newtonsoft.Json.Linq.JObject]$Root,
|
||||
[string]$Name,
|
||||
[string]$Code
|
||||
) {
|
||||
$token = $Root.GetValue($Name, [StringComparison]::Ordinal)
|
||||
if ($null -eq $token -or
|
||||
$token.Type -ne [Newtonsoft.Json.Linq.JTokenType]::String) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
return [string]$token.Value
|
||||
}
|
||||
|
||||
function Assert-SafeText(
|
||||
[object]$Raw,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength,
|
||||
[string]$Code
|
||||
) {
|
||||
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
||||
$value = [string]$Raw
|
||||
if ($value.Length -lt $MinimumLength -or
|
||||
$value.Length -gt $MaximumLength -or
|
||||
$value -cne $value.Trim()) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
foreach ($character in $value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-JsonInt32(
|
||||
[object]$Raw,
|
||||
[int]$Minimum,
|
||||
[int]$Maximum,
|
||||
[string]$Code
|
||||
) {
|
||||
if (($Raw -isnot [int] -and $Raw -isnot [long]) -or
|
||||
[long]$Raw -lt $Minimum -or [long]$Raw -gt $Maximum) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
return [int]$Raw
|
||||
}
|
||||
|
||||
function Read-ApprovalUtc([object]$Raw, [string]$Code) {
|
||||
if ($Raw -isnot [string]) { Throw-FieldValidationError $Code }
|
||||
try {
|
||||
$value = [DateTimeOffset]::ParseExact(
|
||||
[string]$Raw,
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
[Globalization.DateTimeStyles]::AssumeUniversal `
|
||||
-bor [Globalization.DateTimeStyles]::AdjustToUniversal)
|
||||
}
|
||||
catch { Throw-FieldValidationError $Code }
|
||||
if ($value.ToUniversalTime().ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture) -cne
|
||||
[string]$Raw) {
|
||||
Throw-FieldValidationError $Code
|
||||
}
|
||||
return $value.ToUniversalTime()
|
||||
}
|
||||
|
||||
function Write-NewUtf8File([string]$Path, [string]$Text) {
|
||||
$stream = $null
|
||||
$writer = $null
|
||||
$created = $false
|
||||
$succeeded = $false
|
||||
$failure = $null
|
||||
try {
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None)
|
||||
$created = $true
|
||||
$writer = New-Object IO.StreamWriter($stream, $strictUtf8)
|
||||
$writer.Write($Text)
|
||||
$writer.Flush()
|
||||
$stream.Flush($true)
|
||||
$succeeded = $true
|
||||
}
|
||||
catch { $failure = $_ }
|
||||
finally {
|
||||
if ($null -ne $writer) { $writer.Dispose() }
|
||||
elseif ($null -ne $stream) { $stream.Dispose() }
|
||||
if ($created -and -not $succeeded -and [IO.File]::Exists($Path)) {
|
||||
try { [IO.File]::Delete($Path) } catch { }
|
||||
}
|
||||
}
|
||||
if ($null -ne $failure -or -not $succeeded) {
|
||||
Throw-FieldValidationError 'output_publish_failed'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$inputFull = Resolve-RegularFile $InputPath 256KB 'input_file_invalid'
|
||||
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
|
||||
if ([string]::IsNullOrWhiteSpace($PreflightScriptPath)) {
|
||||
$PreflightScriptPath = Join-Path $PSScriptRoot `
|
||||
'Invoke-LserpReadOnlySessionPreflight.ps1'
|
||||
}
|
||||
$preflightFull = Resolve-RegularFile `
|
||||
$PreflightScriptPath 2MB 'preflight_script_invalid'
|
||||
if ([IO.Path]::GetFileName($preflightFull) -cne
|
||||
'Invoke-LserpReadOnlySessionPreflight.ps1' -or
|
||||
$inputFull -ieq $outputFull -or
|
||||
$inputFull -ieq $preflightFull -or
|
||||
$outputFull -ieq $preflightFull) {
|
||||
Throw-FieldValidationError 'trusted_path_conflict'
|
||||
}
|
||||
|
||||
$inputLock = [IO.File]::Open(
|
||||
$inputFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$inputBytes = Read-LockedUtf8Bytes `
|
||||
$inputLock 256KB 'input_file_invalid'
|
||||
$inputHash = Get-Sha256Hex $inputBytes
|
||||
if ($inputHash -cne $ExpectedInputSha256.ToLowerInvariant()) {
|
||||
Throw-FieldValidationError 'input_hash_mismatch'
|
||||
}
|
||||
try { $inputText = $strictUtf8.GetString($inputBytes) }
|
||||
catch { Throw-FieldValidationError 'input_utf8_invalid' }
|
||||
Assert-StrictJsonText $inputText
|
||||
$rawDocument = Read-StrictRootObjectToken $inputText
|
||||
try { $document = $inputText | ConvertFrom-Json }
|
||||
catch { Throw-FieldValidationError 'input_json_invalid' }
|
||||
|
||||
$topLevelProperties = @(
|
||||
'schemaVersion', 'evidenceType', 'validationStage', 'approved',
|
||||
'approvedBy', 'approvedAtUtc', 'approvalExpiresAtUtc',
|
||||
'expectedPreflightScriptSha256', 'cli', 'erp', 'session',
|
||||
'rollout', 'moduleBindings', 'requirements',
|
||||
'bridgeTimeoutMilliseconds', 'databaseCredentialsIncluded',
|
||||
'readOnlyEvidenceOutputPath'
|
||||
)
|
||||
if (-not (Test-ExactProperties $document $topLevelProperties) -or
|
||||
[string]$document.schemaVersion -cne '1.1' -or
|
||||
[string]$document.evidenceType -cne
|
||||
'lserp_field_readonly_validation_input' -or
|
||||
([string]$document.validationStage) -cnotin @(
|
||||
'discovery', 'final') -or
|
||||
$document.approved -isnot [bool] -or
|
||||
$document.approved -ne $true -or
|
||||
$document.databaseCredentialsIncluded -isnot [bool] -or
|
||||
$document.databaseCredentialsIncluded -ne $false) {
|
||||
Throw-FieldValidationError 'input_contract_invalid'
|
||||
}
|
||||
|
||||
Assert-SafeText $document.approvedBy 1 128 'approval_identity_invalid'
|
||||
if ([string]$document.approvedBy -cmatch '(?i)^replace(?:_|$)') {
|
||||
Throw-FieldValidationError 'approval_placeholder_forbidden'
|
||||
}
|
||||
$approvedAt = Read-ApprovalUtc `
|
||||
(Read-RawTopLevelString `
|
||||
$rawDocument 'approvedAtUtc' 'approval_time_invalid') `
|
||||
'approval_time_invalid'
|
||||
$approvalExpiresAt = Read-ApprovalUtc `
|
||||
(Read-RawTopLevelString `
|
||||
$rawDocument 'approvalExpiresAtUtc' 'approval_expiry_invalid') `
|
||||
'approval_expiry_invalid'
|
||||
$now = [DateTimeOffset]::UtcNow
|
||||
if ($approvedAt -gt $now.AddMinutes(5) -or
|
||||
$approvalExpiresAt -le $approvedAt -or
|
||||
$approvalExpiresAt -gt $approvedAt.AddHours(24) -or
|
||||
$approvalExpiresAt -le $now) {
|
||||
Throw-FieldValidationError 'approval_window_invalid'
|
||||
}
|
||||
|
||||
$expectedPreflightHash =
|
||||
([string]$document.expectedPreflightScriptSha256).ToLowerInvariant()
|
||||
if ($expectedPreflightHash -cnotmatch $safeSha256 -or
|
||||
$expectedPreflightHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'preflight_hash_invalid'
|
||||
}
|
||||
|
||||
if (-not (Test-ExactProperties $document.cli @(
|
||||
'path', 'version', 'sha256', 'signerThumbprint')) -or
|
||||
-not (Test-ExactProperties $document.erp @(
|
||||
'processId', 'sha256')) -or
|
||||
-not (Test-ExactProperties $document.session @(
|
||||
'databaseScopeFingerprint', 'userId', 'userName', 'accountBook',
|
||||
'subSystemId', 'expectedIsAdministrator')) -or
|
||||
-not (Test-ExactProperties $document.rollout @(
|
||||
'customerId', 'policySha256')) -or
|
||||
-not (Test-ExactProperties $document.requirements @(
|
||||
'purchaseWorkflow', 'leaveWorkflow',
|
||||
'diagnosisWorkflow'))) {
|
||||
Throw-FieldValidationError 'input_nested_contract_invalid'
|
||||
}
|
||||
|
||||
Assert-SafeText $document.cli.path 1 1024 'cli_path_invalid'
|
||||
if ([string]$document.cli.path -cnotmatch
|
||||
'(?i)(?:^|[\\/])lserp-agent-cli\.exe$' -or
|
||||
[string]$document.cli.path -cmatch '(?i)replace(?:_|[\\/])') {
|
||||
Throw-FieldValidationError 'cli_path_invalid'
|
||||
}
|
||||
$cliVersion = [string]$document.cli.version
|
||||
$cliHash = ([string]$document.cli.sha256).ToLowerInvariant()
|
||||
$signerThumbprint = [string]$document.cli.signerThumbprint
|
||||
if ($cliVersion -cnotmatch $safeCliVersion -or
|
||||
$cliHash -cnotmatch $safeSha256 -or
|
||||
$cliHash -ceq ('0' * 64) -or
|
||||
$signerThumbprint -cnotmatch '^[A-F0-9]{40}$' -or
|
||||
$signerThumbprint -ceq ('0' * 40)) {
|
||||
Throw-FieldValidationError 'cli_identity_invalid'
|
||||
}
|
||||
|
||||
$erpProcessId = Read-JsonInt32 `
|
||||
$document.erp.processId 1 2147483647 'erp_process_id_invalid'
|
||||
$erpHash = ([string]$document.erp.sha256).ToLowerInvariant()
|
||||
if ($erpHash -cnotmatch $safeSha256 -or
|
||||
$erpHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'erp_hash_invalid'
|
||||
}
|
||||
|
||||
$databaseScope =
|
||||
([string]$document.session.databaseScopeFingerprint).ToLowerInvariant()
|
||||
if ($databaseScope -cnotmatch $safeSha256 -or
|
||||
$databaseScope -ceq ('0' * 64) -or
|
||||
$document.session.expectedIsAdministrator -isnot [bool]) {
|
||||
Throw-FieldValidationError 'session_scope_invalid'
|
||||
}
|
||||
foreach ($property in @(
|
||||
'userId', 'userName', 'accountBook', 'subSystemId')) {
|
||||
Assert-SafeText `
|
||||
$document.session.$property 1 256 'session_scope_invalid'
|
||||
if ([string]$document.session.$property -cmatch
|
||||
'(?i)^replace(?:_|$)') {
|
||||
Throw-FieldValidationError 'session_scope_placeholder_forbidden'
|
||||
}
|
||||
}
|
||||
|
||||
Assert-SafeText $document.rollout.customerId 1 128 `
|
||||
'rollout_identity_invalid'
|
||||
$rolloutCustomerId = [string]$document.rollout.customerId
|
||||
$rolloutPolicyHash =
|
||||
([string]$document.rollout.policySha256).ToLowerInvariant()
|
||||
if ($rolloutCustomerId -cnotmatch
|
||||
'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$' -or
|
||||
$rolloutCustomerId -cmatch '(?i)^replace(?:_|$)' -or
|
||||
$rolloutPolicyHash -cnotmatch $safeSha256 -or
|
||||
$rolloutPolicyHash -ceq ('0' * 64)) {
|
||||
Throw-FieldValidationError 'rollout_identity_invalid'
|
||||
}
|
||||
|
||||
foreach ($name in @(
|
||||
'purchaseWorkflow', 'leaveWorkflow', 'diagnosisWorkflow')) {
|
||||
if ($document.requirements.$name -isnot [bool]) {
|
||||
Throw-FieldValidationError 'workflow_requirements_invalid'
|
||||
}
|
||||
}
|
||||
$requirePurchase = [bool]$document.requirements.purchaseWorkflow
|
||||
$requireLeave = [bool]$document.requirements.leaveWorkflow
|
||||
$requireDiagnosis = [bool]$document.requirements.diagnosisWorkflow
|
||||
if ($requireDiagnosis -and
|
||||
-not [bool]$document.session.expectedIsAdministrator) {
|
||||
Throw-FieldValidationError `
|
||||
'diagnosis_requires_expected_administrator'
|
||||
}
|
||||
|
||||
if (-not (Test-JsonArray $document.moduleBindings)) {
|
||||
Throw-FieldValidationError 'module_bindings_invalid'
|
||||
}
|
||||
$rawBindings = @($document.moduleBindings)
|
||||
if ($rawBindings.Count -lt 1 -or $rawBindings.Count -gt 16) {
|
||||
Throw-FieldValidationError 'module_bindings_invalid'
|
||||
}
|
||||
$pairSet = New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
$moduleSet = New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::OrdinalIgnoreCase)
|
||||
$moduleCodes = New-Object System.Collections.Generic.List[string]
|
||||
$normalizedBindings = New-Object System.Collections.Generic.List[string]
|
||||
$roleCounts = [ordered]@{
|
||||
purchase = 0
|
||||
leave = 0
|
||||
diagnosis = 0
|
||||
support = 0
|
||||
}
|
||||
foreach ($binding in $rawBindings) {
|
||||
if (-not (Test-ExactProperties $binding @('role', 'moduleCode')) -or
|
||||
$binding.role -isnot [string] -or
|
||||
$binding.moduleCode -isnot [string]) {
|
||||
Throw-FieldValidationError 'module_binding_contract_invalid'
|
||||
}
|
||||
$role = [string]$binding.role
|
||||
$moduleCode = [string]$binding.moduleCode
|
||||
if ($role -cnotin @('purchase', 'leave', 'diagnosis', 'support') -or
|
||||
$moduleCode -cnotmatch $safeModuleCode -or
|
||||
$moduleCode -cmatch '(?i)^replace(?:_|$)' -or
|
||||
-not $pairSet.Add($role + '|' + $moduleCode.ToUpperInvariant())) {
|
||||
Throw-FieldValidationError 'module_binding_contract_invalid'
|
||||
}
|
||||
$roleCounts[$role] = [int]$roleCounts[$role] + 1
|
||||
$normalizedBindings.Add($role + '|' + $moduleCode)
|
||||
if ($moduleSet.Add($moduleCode)) { $moduleCodes.Add($moduleCode) }
|
||||
}
|
||||
if ($moduleCodes.Count -lt 1 -or $moduleCodes.Count -gt 16 -or
|
||||
($requirePurchase -and [int]$roleCounts.purchase -eq 0) -or
|
||||
($requireLeave -and [int]$roleCounts.leave -eq 0) -or
|
||||
($requireDiagnosis -and [int]$roleCounts.diagnosis -eq 0)) {
|
||||
Throw-FieldValidationError 'required_module_role_missing'
|
||||
}
|
||||
if ([string]$document.validationStage -ceq 'final') {
|
||||
if (-not ($requirePurchase -or $requireLeave -or $requireDiagnosis) -or
|
||||
([int]$roleCounts.purchase -gt 0) -ne $requirePurchase -or
|
||||
([int]$roleCounts.leave -gt 0) -ne $requireLeave -or
|
||||
([int]$roleCounts.diagnosis -gt 0) -ne $requireDiagnosis) {
|
||||
Throw-FieldValidationError 'final_workflow_role_mismatch'
|
||||
}
|
||||
}
|
||||
|
||||
$bridgeTimeout = Read-JsonInt32 `
|
||||
$document.bridgeTimeoutMilliseconds 1000 300000 `
|
||||
'bridge_timeout_invalid'
|
||||
Assert-SafeText $document.readOnlyEvidenceOutputPath 1 1024 `
|
||||
'readonly_evidence_output_path_invalid'
|
||||
$readOnlyEvidenceOutputPath =
|
||||
[string]$document.readOnlyEvidenceOutputPath
|
||||
if ($readOnlyEvidenceOutputPath -cnotmatch '(?i)\.json$' -or
|
||||
$readOnlyEvidenceOutputPath -cmatch '(?i)replace(?:_|[\\/])') {
|
||||
Throw-FieldValidationError 'readonly_evidence_output_path_invalid'
|
||||
}
|
||||
|
||||
$preflightLock = [IO.File]::Open(
|
||||
$preflightFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$actualPreflightHash = Get-StreamSha256 $preflightLock
|
||||
if ($actualPreflightHash -cne $expectedPreflightHash) {
|
||||
Throw-FieldValidationError 'preflight_hash_mismatch'
|
||||
}
|
||||
|
||||
$orderedBindings = $normalizedBindings.ToArray()
|
||||
[Array]::Sort($orderedBindings, [StringComparer]::Ordinal)
|
||||
$moduleBindingFingerprint = Get-ScopedValueSha256 `
|
||||
'module-bindings' ($orderedBindings -join "`n")
|
||||
$sessionApprovalFingerprint = Get-ScopedValueSha256 `
|
||||
'session-approval' (@(
|
||||
$databaseScope,
|
||||
[string]$document.session.userId,
|
||||
[string]$document.session.userName,
|
||||
[string]$document.session.accountBook,
|
||||
[string]$document.session.subSystemId,
|
||||
([string][bool]$document.session.expectedIsAdministrator)
|
||||
) -join "`n")
|
||||
$handoffContractSha256 = Get-ScopedValueSha256 `
|
||||
'preflight-handoff' (@(
|
||||
$expectedPreflightHash,
|
||||
[string]$document.cli.path,
|
||||
$cliVersion,
|
||||
$cliHash,
|
||||
$signerThumbprint,
|
||||
[string]$erpProcessId,
|
||||
$erpHash,
|
||||
$databaseScope,
|
||||
[string]$document.session.userId,
|
||||
[string]$document.session.userName,
|
||||
[string]$document.session.accountBook,
|
||||
[string]$document.session.subSystemId,
|
||||
([string][bool]$document.session.expectedIsAdministrator),
|
||||
$rolloutCustomerId,
|
||||
$rolloutPolicyHash,
|
||||
($moduleCodes.ToArray() -join ','),
|
||||
([string]$requirePurchase),
|
||||
([string]$requireLeave),
|
||||
([string]$requireDiagnosis),
|
||||
[string]$bridgeTimeout,
|
||||
$readOnlyEvidenceOutputPath
|
||||
) -join "`n")
|
||||
|
||||
$preflightEvidenceHash = $null
|
||||
if (-not $ValidateInputOnly) {
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
Throw-FieldValidationError `
|
||||
'windows_powershell_51_required_for_execution'
|
||||
}
|
||||
$preflightEvidenceFull = Resolve-NewJsonPath `
|
||||
$readOnlyEvidenceOutputPath 'readonly_evidence_output_path_invalid'
|
||||
foreach ($trustedPath in @(
|
||||
$inputFull, $outputFull, $preflightFull)) {
|
||||
if ($preflightEvidenceFull -ieq $trustedPath) {
|
||||
Throw-FieldValidationError 'trusted_path_conflict'
|
||||
}
|
||||
}
|
||||
$preflightArguments = @{
|
||||
CliPath = [string]$document.cli.path
|
||||
ExpectedCliVersion = $cliVersion
|
||||
ExpectedCliSha256 = $cliHash
|
||||
ExpectedSignerThumbprint = $signerThumbprint
|
||||
ErpProcessId = $erpProcessId
|
||||
ExpectedErpSha256 = $erpHash
|
||||
ExpectedDatabaseScopeFingerprint = $databaseScope
|
||||
ExpectedUserId = [string]$document.session.userId
|
||||
ExpectedUserName = [string]$document.session.userName
|
||||
ExpectedAccountBook = [string]$document.session.accountBook
|
||||
ExpectedSubSystemId = [string]$document.session.subSystemId
|
||||
ExpectedIsAdministrator =
|
||||
[bool]$document.session.expectedIsAdministrator
|
||||
ExpectedRolloutCustomerId = $rolloutCustomerId
|
||||
ExpectedRolloutPolicySha256 = $rolloutPolicyHash
|
||||
ModuleCodes = $moduleCodes.ToArray()
|
||||
OutputPath = $preflightEvidenceFull
|
||||
BridgeTimeoutMilliseconds = $bridgeTimeout
|
||||
}
|
||||
if ($requirePurchase) {
|
||||
$preflightArguments.RequirePurchaseWorkflow = $true
|
||||
}
|
||||
if ($requireLeave) {
|
||||
$preflightArguments.RequireLeaveWorkflow = $true
|
||||
}
|
||||
if ($requireDiagnosis) {
|
||||
$preflightArguments.RequireDiagnosisWorkflow = $true
|
||||
}
|
||||
$executionAttempted = $true
|
||||
$preflightOutput = @(& $preflightFull @preflightArguments)
|
||||
if ($preflightOutput.Count -ne 1 -or
|
||||
$preflightOutput[0] -isnot [string] -or
|
||||
[IO.Path]::GetFullPath([string]$preflightOutput[0]) -ine
|
||||
$preflightEvidenceFull) {
|
||||
Throw-FieldValidationError 'preflight_output_invalid'
|
||||
}
|
||||
$evidenceFull = Resolve-RegularFile `
|
||||
$preflightEvidenceFull 4MB 'preflight_evidence_invalid'
|
||||
$evidenceLock = $null
|
||||
try {
|
||||
$evidenceLock = [IO.File]::Open(
|
||||
$evidenceFull,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$evidenceBytes = Read-LockedUtf8Bytes `
|
||||
$evidenceLock 4MB 'preflight_evidence_invalid'
|
||||
try { $evidenceText = $strictUtf8.GetString($evidenceBytes) }
|
||||
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
||||
Assert-StrictJsonText $evidenceText
|
||||
try { $evidence = $evidenceText | ConvertFrom-Json }
|
||||
catch { Throw-FieldValidationError 'preflight_evidence_invalid' }
|
||||
if ([string]$evidence.schemaVersion -cne '1.5' -or
|
||||
[string]$evidence.evidenceType -cne
|
||||
'lserp_readonly_session_preflight' -or
|
||||
$evidence.passed -ne $true -or
|
||||
$evidence.readOnlySessionReady -ne $true -or
|
||||
$evidence.productionWriteAuthorized -ne $false -or
|
||||
-not (Test-ExactProperties $evidence.cli @(
|
||||
'component', 'version', 'protocolVersion',
|
||||
'bridgeOnly', 'databaseDirectAccess', 'sessionSource',
|
||||
'sha256', 'signerThumbprint')) -or
|
||||
[string]$evidence.cli.component -cne 'lserp-agent-cli' -or
|
||||
[string]$evidence.cli.version -cne $cliVersion -or
|
||||
[string]$evidence.cli.protocolVersion -cne '1.0' -or
|
||||
$evidence.cli.bridgeOnly -ne $true -or
|
||||
$evidence.cli.databaseDirectAccess -ne $false -or
|
||||
[string]$evidence.cli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
[string]$evidence.cli.sha256 -cne $cliHash -or
|
||||
[string]$evidence.cli.signerThumbprint -cne
|
||||
$signerThumbprint) {
|
||||
Throw-FieldValidationError 'preflight_evidence_invalid'
|
||||
}
|
||||
$preflightEvidenceHash = Get-Sha256Hex $evidenceBytes
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $evidenceLock) { $evidenceLock.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
if ((Get-StreamSha256 $inputLock) -cne $inputHash) {
|
||||
Throw-FieldValidationError 'input_changed_during_validation'
|
||||
}
|
||||
if ((Get-StreamSha256 $preflightLock) -cne $expectedPreflightHash) {
|
||||
Throw-FieldValidationError 'preflight_changed_during_validation'
|
||||
}
|
||||
|
||||
$report = [ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
evidenceType = 'lserp_field_readonly_validation_handoff'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||
passed = $true
|
||||
validationStage = [string]$document.validationStage
|
||||
inputSha256 = $inputHash
|
||||
approvedBySha256 = Get-ScopedValueSha256 `
|
||||
'approved-by' ([string]$document.approvedBy)
|
||||
approvedAtUtc = $approvedAt.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
approvalExpiresAtUtc = $approvalExpiresAt.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
expectedPreflightScriptSha256 = $expectedPreflightHash
|
||||
handoffContractSha256 = $handoffContractSha256
|
||||
runtimeCli = [ordered]@{
|
||||
component = 'lserp-agent-cli'
|
||||
version = $cliVersion
|
||||
sha256 = $cliHash
|
||||
signerThumbprint = $signerThumbprint
|
||||
}
|
||||
sessionApprovalFingerprint = $sessionApprovalFingerprint
|
||||
erpProcessId = $erpProcessId
|
||||
expectedIsAdministrator =
|
||||
[bool]$document.session.expectedIsAdministrator
|
||||
moduleBindingFingerprint = $moduleBindingFingerprint
|
||||
moduleBindingCount = $rawBindings.Count
|
||||
uniqueModuleCount = $moduleCodes.Count
|
||||
moduleRoleCounts = $roleCounts
|
||||
requestedReadiness = [ordered]@{
|
||||
purchaseWorkflow = $requirePurchase
|
||||
leaveWorkflow = $requireLeave
|
||||
diagnosisWorkflow = $requireDiagnosis
|
||||
}
|
||||
databaseCredentialsIncluded = $false
|
||||
validationOnly = [bool]$ValidateInputOnly
|
||||
executionAttempted = $executionAttempted
|
||||
preflightEvidenceProduced = -not [bool]$ValidateInputOnly
|
||||
preflightEvidenceSha256 = $preflightEvidenceHash
|
||||
productionWriteAuthorized = $false
|
||||
readOnlyBoundary = [ordered]@{
|
||||
directDatabaseConnectionUsed = $false
|
||||
databaseCredentialAccepted = $false
|
||||
businessWriteAttempted = $false
|
||||
rawSessionValuesEmitted = $false
|
||||
rawModuleCodesEmitted = $false
|
||||
preflightExecuteCommandAllowed = $false
|
||||
}
|
||||
checks = @(
|
||||
[ordered]@{ code = 'strict_json'; passed = $true },
|
||||
[ordered]@{ code = 'input_integrity'; passed = $true },
|
||||
[ordered]@{ code = 'independent_approval_window'; passed = $true },
|
||||
[ordered]@{ code = 'database_credentials_absent'; passed = $true },
|
||||
[ordered]@{ code = 'exact_session_expectations'; passed = $true },
|
||||
[ordered]@{ code = 'dynamic_module_role_bindings'; passed = $true },
|
||||
[ordered]@{ code = 'preflight_source_integrity'; passed = $true },
|
||||
[ordered]@{ code = 'readonly_parameter_handoff'; passed = $true }
|
||||
)
|
||||
note = '本报告证明经独立审核并锁定哈希的 PID、会话范围和动态模块角色已完整交给只读预检;它不包含数据库凭据,不自动信任 Bridge 自报范围,也不授权任何业务写入。'
|
||||
}
|
||||
Write-NewUtf8File `
|
||||
$outputFull (($report | ConvertTo-Json -Depth 8) +
|
||||
[Environment]::NewLine)
|
||||
$published = $true
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $preflightLock) { $preflightLock.Dispose() }
|
||||
if ($null -ne $inputLock) { $inputLock.Dispose() }
|
||||
if (-not $published -and $null -ne $outputFull -and
|
||||
[IO.File]::Exists($outputFull)) {
|
||||
try { [IO.File]::Delete($outputFull) } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output $outputFull
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,753 @@
|
||||
[CmdletBinding(DefaultParameterSetName = 'SqlCredential')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 260)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')]
|
||||
[string]$Database,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'SqlCredential')]
|
||||
[Management.Automation.PSCredential]$Credential,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'WindowsCredential')]
|
||||
[switch]$UseWindowsAuthentication,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
[ValidateRange(5, 60)]
|
||||
[int]$ConnectionTimeoutSeconds = 15,
|
||||
|
||||
[ValidateRange(5, 120)]
|
||||
[int]$CommandTimeoutSeconds = 60
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
throw 'lserp_select_only_catalog_failed:windows_powershell_51_required'
|
||||
}
|
||||
|
||||
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$maximumCatalogEntries = 100000
|
||||
$maximumOutputBytes = 16 * 1024 * 1024
|
||||
$maximumToolBytes = 2 * 1024 * 1024
|
||||
$toolPath = [string]$PSCommandPath
|
||||
$connection = $null
|
||||
$connectionBuilder = $null
|
||||
$metadataCommand = $null
|
||||
$metadataReader = $null
|
||||
$outputFull = $null
|
||||
$outputCreated = $false
|
||||
$published = $false
|
||||
|
||||
function Throw-CatalogError([string]$Code) {
|
||||
throw ('lserp_select_only_catalog_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NewJsonPath([string]$Path) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
||||
[IO.File]::Exists($full) -or
|
||||
[IO.Directory]::Exists($full)) {
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
$directory = [IO.Path]::GetDirectoryName($full)
|
||||
if ([string]::IsNullOrWhiteSpace($directory)) {
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $directory 'output_path_invalid'
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafeServer([string]$Value) {
|
||||
if (-not [Text.RegularExpressions.Regex]::IsMatch(
|
||||
$Value,
|
||||
'^[A-Za-z0-9][A-Za-z0-9.-]{0,252}(?:,[1-9][0-9]{0,4})?$')) {
|
||||
Throw-CatalogError 'server_invalid'
|
||||
}
|
||||
$comma = $Value.LastIndexOf(',')
|
||||
if ($comma -ge 0) {
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($Value.Substring($comma + 1), [ref]$port) -or
|
||||
$port -lt 1 -or $port -gt 65535) {
|
||||
Throw-CatalogError 'server_invalid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafeText(
|
||||
[string]$Value,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength,
|
||||
[string]$Code
|
||||
) {
|
||||
if ($null -eq $Value -or
|
||||
$Value.Length -lt $MinimumLength -or
|
||||
$Value.Length -gt $MaximumLength -or
|
||||
-not [string]::Equals(
|
||||
$Value,
|
||||
$Value.Trim(),
|
||||
[StringComparison]::Ordinal)) {
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
foreach ($character in $Value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) { Throw-CatalogError $Code }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Text([string]$Value) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = $utf8.GetBytes($Value)
|
||||
return ([BitConverter]::ToString(
|
||||
$algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$algorithm.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-Int64(
|
||||
[Data.Common.DbDataReader]$Reader,
|
||||
[string]$Name,
|
||||
[long]$Minimum,
|
||||
[long]$Maximum
|
||||
) {
|
||||
try {
|
||||
$ordinal = $Reader.GetOrdinal($Name)
|
||||
if ($Reader.IsDBNull($ordinal)) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
$value = [Convert]::ToInt64(
|
||||
$Reader.GetValue($ordinal),
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
if ($value -lt $Minimum -or $value -gt $Maximum) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
return $value
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-Text(
|
||||
[Data.Common.DbDataReader]$Reader,
|
||||
[string]$Name,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength
|
||||
) {
|
||||
try {
|
||||
$ordinal = $Reader.GetOrdinal($Name)
|
||||
if ($Reader.IsDBNull($ordinal)) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
$value = [Convert]::ToString(
|
||||
$Reader.GetValue($ordinal),
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
Assert-SafeText $value $MinimumLength $MaximumLength `
|
||||
'query_contract_invalid'
|
||||
return $value
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentToolFileSha256([string]$Path) {
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.PSIsContainer -or
|
||||
$item.Length -le 0 -or
|
||||
$item.Length -gt $maximumToolBytes -or
|
||||
(($item.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $item.DirectoryName `
|
||||
'tool_source_invalid'
|
||||
$hash = (Get-FileHash -LiteralPath $full `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($hash -cnotmatch '^[0-9a-f]{64}$') {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SelectOnlyPermissionGate(
|
||||
[Data.Common.DbConnection]$connection,
|
||||
[string]$permissionQuery,
|
||||
[int]$commandTimeoutSeconds
|
||||
) {
|
||||
$permissionCommand = $null
|
||||
$permissionReader = $null
|
||||
try {
|
||||
$permissionCommand = $connection.CreateCommand()
|
||||
$permissionCommand.CommandType = [Data.CommandType]::Text
|
||||
$permissionCommand.CommandText = $permissionQuery
|
||||
$permissionCommand.CommandTimeout = $commandTimeoutSeconds
|
||||
$permissionReader = $permissionCommand.ExecuteReader(
|
||||
[Data.CommandBehavior]::SingleResult)
|
||||
if (-not $permissionReader.Read()) {
|
||||
Throw-CatalogError 'permission_contract_invalid'
|
||||
}
|
||||
$permissionColumns = @(
|
||||
'database_control', 'database_alter', 'database_insert',
|
||||
'database_update', 'database_delete', 'database_execute',
|
||||
'database_create_table', 'database_create_procedure',
|
||||
'database_create_view', 'database_create_function',
|
||||
'server_control', 'server_alter_login', 'server_impersonate_login',
|
||||
'sysadmin_member', 'db_owner_member', 'db_ddladmin_member',
|
||||
'db_datawriter_member', 'writable_object_count',
|
||||
'executable_object_count', 'writable_schema_count'
|
||||
)
|
||||
foreach ($column in $permissionColumns) {
|
||||
if ((Read-Int64 $permissionReader $column 0 10000000) -ne 0) {
|
||||
Throw-CatalogError 'database_principal_not_select_only'
|
||||
}
|
||||
}
|
||||
if ($permissionReader.Read() -or $permissionReader.NextResult()) {
|
||||
Throw-CatalogError 'permission_contract_invalid'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $permissionReader) { $permissionReader.Dispose() }
|
||||
if ($null -ne $permissionCommand) { $permissionCommand.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function New-RestrictedFileSecurity {
|
||||
try {
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
||||
$null)
|
||||
$security = New-Object Security.AccessControl.FileSecurity
|
||||
$security.SetOwner($currentUser)
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$allow = [Security.AccessControl.AccessControlType]::Allow
|
||||
foreach ($identity in @($currentUser, $localSystem)) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$identity,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
$allow)
|
||||
[void]$security.AddAccessRule($rule)
|
||||
}
|
||||
return $security
|
||||
}
|
||||
catch {
|
||||
Throw-CatalogError 'output_acl_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-RestrictedJson([string]$Path, [string]$Json) {
|
||||
$stream = $null
|
||||
$writer = $null
|
||||
try {
|
||||
$bytes = $utf8.GetByteCount($Json)
|
||||
if ($bytes -le 0 -or $bytes -gt $maximumOutputBytes) {
|
||||
Throw-CatalogError 'output_size_invalid'
|
||||
}
|
||||
$security = New-RestrictedFileSecurity
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[Security.AccessControl.FileSystemRights]::Write,
|
||||
[IO.FileShare]::None,
|
||||
4096,
|
||||
[IO.FileOptions]::WriteThrough,
|
||||
$security)
|
||||
$script:outputCreated = $true
|
||||
$writer = [IO.StreamWriter]::new($stream, $utf8, 4096, $false)
|
||||
$stream = $null
|
||||
$writer.Write($Json)
|
||||
$writer.Flush()
|
||||
$writer.Dispose()
|
||||
$writer = $null
|
||||
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.Length -le 0 -or $item.Length -gt $maximumOutputBytes -or
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError 'output_publish_invalid'
|
||||
}
|
||||
$acl = [IO.File]::GetAccessControl($Path)
|
||||
if (-not $acl.AreAccessRulesProtected) {
|
||||
Throw-CatalogError 'output_acl_invalid'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $writer) { $writer.Dispose() }
|
||||
if ($null -ne $stream) { $stream.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
$permissionQuery = @'
|
||||
SELECT
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONTROL'), 0)) AS database_control,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER'), 0)) AS database_alter,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'INSERT'), 0)) AS database_insert,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'UPDATE'), 0)) AS database_update,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'DELETE'), 0)) AS database_delete,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'EXECUTE'), 0)) AS database_execute,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE TABLE'), 0)) AS database_create_table,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE PROCEDURE'), 0)) AS database_create_procedure,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE VIEW'), 0)) AS database_create_view,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE FUNCTION'), 0)) AS database_create_function,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'CONTROL SERVER'), 0)) AS server_control,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER ANY LOGIN'), 0)) AS server_alter_login,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'IMPERSONATE ANY LOGIN'), 0)) AS server_impersonate_login,
|
||||
CONVERT(int, ISNULL(IS_SRVROLEMEMBER('sysadmin'), 0)) AS sysadmin_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_owner'), 0)) AS db_owner_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_ddladmin'), 0)) AS db_ddladmin_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_datawriter'), 0)) AS db_datawriter_member,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects AS writable_object
|
||||
WHERE writable_object.is_ms_shipped = 0
|
||||
AND writable_object.type IN ('U', 'V')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'INSERT'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'UPDATE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'DELETE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS writable_object_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects AS executable_object
|
||||
WHERE executable_object.is_ms_shipped = 0
|
||||
AND executable_object.type IN ('P', 'PC', 'FN', 'IF', 'TF')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'EXECUTE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS executable_object_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.schemas AS writable_schema
|
||||
WHERE writable_schema.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(writable_schema.name), 'SCHEMA', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(writable_schema.name), 'SCHEMA', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS writable_schema_count;
|
||||
'@
|
||||
|
||||
$metadataQuery = @'
|
||||
SELECT
|
||||
CONVERT(nvarchar(128), DB_NAME()) AS database_name,
|
||||
CONVERT(nvarchar(128), SERVERPROPERTY('ServerName')) AS actual_server_name,
|
||||
CONVERT(nvarchar(128), SUSER_SNAME()) AS effective_principal,
|
||||
CONVERT(int, SERVERPROPERTY('ProductMajorVersion')) AS sql_server_major_version,
|
||||
CONVERT(int, current_database.compatibility_level) AS compatibility_level,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.tables WHERE is_ms_shipped = 0)) AS user_table_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.views WHERE is_ms_shipped = 0)) AS user_view_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.procedures WHERE is_ms_shipped = 0)) AS user_procedure_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.triggers WHERE is_ms_shipped = 0)) AS user_trigger_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects
|
||||
WHERE schema_id = SCHEMA_ID(N'dbo')
|
||||
AND
|
||||
(
|
||||
(type = 'U' AND name IN
|
||||
(
|
||||
N'p_agent_business_audit',
|
||||
N'p_agent_business_source_document',
|
||||
N'p_agent_command_idempotency',
|
||||
N'p_agent_integration_outbox',
|
||||
N'p_agent_purchase_currency_crosswalk',
|
||||
N'p_agent_purchase_row_scope',
|
||||
N'p_agent_workflow_adapter_evidence',
|
||||
N'p_agent_workflow_adapter_evidence_v2'
|
||||
))
|
||||
OR
|
||||
(type = 'P' AND name IN
|
||||
(
|
||||
N'p_lserp_agent_workflow_read',
|
||||
N'p_lserp_agent_workflow_read_compat100',
|
||||
N'p_lserp_agent_workflow_readiness',
|
||||
N'p_lserp_agent_workflow_readiness_v2',
|
||||
N'p_lserp_agent_workflow_readiness_v3',
|
||||
N'p_lserp_agent_workflow_write',
|
||||
N'p_lserp_agent_workflow_write_leave_compat100',
|
||||
N'p_lserp_agent_workflow_write_purchase_compat100'
|
||||
))
|
||||
)
|
||||
)) AS agent_workflow_object_count
|
||||
FROM sys.databases AS current_database
|
||||
WHERE current_database.database_id = DB_ID();
|
||||
|
||||
SELECT TOP (100001)
|
||||
catalog_entry.entry_kind,
|
||||
catalog_entry.schema_name,
|
||||
catalog_entry.object_name,
|
||||
catalog_entry.object_kind,
|
||||
catalog_entry.member_name
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'object') AS entry_kind,
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)) AS schema_name,
|
||||
CONVERT(nvarchar(128), catalog_object.name) AS object_name,
|
||||
CONVERT(varchar(16), CASE catalog_object.type
|
||||
WHEN 'U' THEN 'table'
|
||||
WHEN 'V' THEN 'view'
|
||||
WHEN 'P' THEN 'procedure'
|
||||
ELSE 'invalid' END) AS object_kind,
|
||||
CONVERT(nvarchar(128), N'') AS member_name
|
||||
FROM sys.objects AS catalog_object
|
||||
WHERE catalog_object.is_ms_shipped = 0
|
||||
AND catalog_object.type IN ('U', 'V', 'P')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'column'),
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)),
|
||||
CONVERT(nvarchar(128), catalog_object.name),
|
||||
CONVERT(varchar(16), CASE catalog_object.type
|
||||
WHEN 'U' THEN 'table'
|
||||
WHEN 'V' THEN 'view'
|
||||
ELSE 'invalid' END),
|
||||
CONVERT(nvarchar(128), catalog_column.name)
|
||||
FROM sys.objects AS catalog_object
|
||||
INNER JOIN sys.columns AS catalog_column
|
||||
ON catalog_column.object_id = catalog_object.object_id
|
||||
WHERE catalog_object.is_ms_shipped = 0
|
||||
AND catalog_object.type IN ('U', 'V')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'parameter'),
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_procedure.schema_id)),
|
||||
CONVERT(nvarchar(128), catalog_procedure.name),
|
||||
CONVERT(varchar(16), 'procedure'),
|
||||
CONVERT(nvarchar(128), catalog_parameter.name)
|
||||
FROM sys.procedures AS catalog_procedure
|
||||
INNER JOIN sys.parameters AS catalog_parameter
|
||||
ON catalog_parameter.object_id = catalog_procedure.object_id
|
||||
AND catalog_parameter.parameter_id > 0
|
||||
WHERE catalog_procedure.is_ms_shipped = 0
|
||||
) AS catalog_entry
|
||||
ORDER BY
|
||||
catalog_entry.object_kind,
|
||||
catalog_entry.schema_name,
|
||||
catalog_entry.object_name,
|
||||
catalog_entry.entry_kind,
|
||||
catalog_entry.member_name;
|
||||
'@
|
||||
|
||||
try {
|
||||
Assert-SafeServer $Server
|
||||
$outputFull = Resolve-NewJsonPath $OutputPath
|
||||
$executedScriptText = $null
|
||||
try {
|
||||
$executedScriptText = [string]
|
||||
$MyInvocation.MyCommand.ScriptBlock.Ast.Extent.Text
|
||||
}
|
||||
catch {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($executedScriptText) -or
|
||||
$utf8.GetByteCount($executedScriptText) -gt $maximumToolBytes) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
$executedToolSha256 = Get-Sha256Text $executedScriptText
|
||||
$initialToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
|
||||
if ($initialToolFileSha256 -cne $executedToolSha256) {
|
||||
Throw-CatalogError 'tool_source_changed'
|
||||
}
|
||||
if ($null -ne $Credential) {
|
||||
Assert-SafeText $Credential.UserName 1 128 'credential_user_invalid'
|
||||
}
|
||||
|
||||
$connectionBuilder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$connectionBuilder.DataSource = $Server
|
||||
$connectionBuilder.InitialCatalog = $Database
|
||||
$connectionBuilder.ConnectTimeout = $ConnectionTimeoutSeconds
|
||||
$connectionBuilder.Encrypt = $true
|
||||
$connectionBuilder.TrustServerCertificate = $false
|
||||
$connectionBuilder.ApplicationIntent =
|
||||
[System.Data.SqlClient.ApplicationIntent]::ReadOnly
|
||||
$connectionBuilder.PersistSecurityInfo = $false
|
||||
$connectionBuilder.Pooling = $false
|
||||
$connectionBuilder.MultipleActiveResultSets = $false
|
||||
$connectionBuilder.ApplicationName =
|
||||
'Langsu Lserp SELECT-only catalog snapshot'
|
||||
if ($PSCmdlet.ParameterSetName -eq 'WindowsCredential') {
|
||||
$connectionBuilder.IntegratedSecurity = $true
|
||||
}
|
||||
else {
|
||||
$connectionBuilder.IntegratedSecurity = $false
|
||||
$connectionBuilder.UserID = $Credential.UserName
|
||||
$connectionBuilder.Password = $Credential.GetNetworkCredential().Password
|
||||
}
|
||||
|
||||
$connection = [System.Data.SqlClient.SqlConnection]::new(
|
||||
$connectionBuilder.ConnectionString)
|
||||
$connection.Open()
|
||||
|
||||
Assert-SelectOnlyPermissionGate `
|
||||
$connection $permissionQuery $CommandTimeoutSeconds
|
||||
|
||||
$metadataCommand = $connection.CreateCommand()
|
||||
$metadataCommand.CommandType = [Data.CommandType]::Text
|
||||
$metadataCommand.CommandText = $metadataQuery
|
||||
$metadataCommand.CommandTimeout = $CommandTimeoutSeconds
|
||||
$metadataReader = $metadataCommand.ExecuteReader(
|
||||
[Data.CommandBehavior]::SequentialAccess)
|
||||
if (-not $metadataReader.Read()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
$actualDatabase = Read-Text $metadataReader 'database_name' 1 128
|
||||
$actualServer = Read-Text $metadataReader 'actual_server_name' 1 128
|
||||
$effectivePrincipal = Read-Text $metadataReader 'effective_principal' 1 128
|
||||
if (-not [string]::Equals(
|
||||
$actualDatabase,
|
||||
$Database,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
Throw-CatalogError 'database_identity_mismatch'
|
||||
}
|
||||
$sqlServerMajorVersion = Read-Int64 `
|
||||
$metadataReader 'sql_server_major_version' 9 99
|
||||
$compatibilityLevel = Read-Int64 `
|
||||
$metadataReader 'compatibility_level' 80 200
|
||||
$userTableCount = Read-Int64 `
|
||||
$metadataReader 'user_table_count' 0 10000000
|
||||
$userViewCount = Read-Int64 `
|
||||
$metadataReader 'user_view_count' 0 10000000
|
||||
$userProcedureCount = Read-Int64 `
|
||||
$metadataReader 'user_procedure_count' 0 10000000
|
||||
$userTriggerCount = Read-Int64 `
|
||||
$metadataReader 'user_trigger_count' 0 10000000
|
||||
$agentWorkflowObjectCount = Read-Int64 `
|
||||
$metadataReader 'agent_workflow_object_count' 0 16
|
||||
if ($metadataReader.Read() -or -not $metadataReader.NextResult()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
|
||||
$catalogHashSet = [Collections.Generic.HashSet[string]]::new(
|
||||
[StringComparer]::Ordinal)
|
||||
while ($metadataReader.Read()) {
|
||||
if ($catalogHashSet.Count -ge $maximumCatalogEntries) {
|
||||
Throw-CatalogError 'catalog_too_large'
|
||||
}
|
||||
$entryKind = Read-Text $metadataReader 'entry_kind' 1 16
|
||||
$schemaName = Read-Text $metadataReader 'schema_name' 1 128
|
||||
$objectName = Read-Text $metadataReader 'object_name' 1 128
|
||||
$objectKind = Read-Text $metadataReader 'object_kind' 1 16
|
||||
$memberMinimum = if ($entryKind -eq 'object') { 0 } else { 1 }
|
||||
$memberName = Read-Text `
|
||||
$metadataReader 'member_name' $memberMinimum 128
|
||||
if ($entryKind -cnotin @('object', 'column', 'parameter') -or
|
||||
$objectKind -cnotin @('table', 'view', 'procedure') -or
|
||||
($entryKind -eq 'object' -and $memberName.Length -ne 0) -or
|
||||
($entryKind -eq 'column' -and $objectKind -eq 'procedure') -or
|
||||
($entryKind -eq 'parameter' -and $objectKind -ne 'procedure')) {
|
||||
Throw-CatalogError 'catalog_contract_invalid'
|
||||
}
|
||||
$catalogKey = [string]::Join(
|
||||
[char]0x1f,
|
||||
@(
|
||||
$entryKind,
|
||||
$schemaName.ToLowerInvariant(),
|
||||
$objectName.ToLowerInvariant(),
|
||||
$objectKind,
|
||||
$memberName.ToLowerInvariant()
|
||||
))
|
||||
$entryHash = Get-Sha256Text $catalogKey
|
||||
if (-not $catalogHashSet.Add($entryHash)) {
|
||||
Throw-CatalogError 'catalog_contract_invalid'
|
||||
}
|
||||
}
|
||||
if ($catalogHashSet.Count -le 0 -or $metadataReader.NextResult()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
$metadataReader.Dispose()
|
||||
$metadataReader = $null
|
||||
|
||||
Assert-SelectOnlyPermissionGate `
|
||||
$connection $permissionQuery $CommandTimeoutSeconds
|
||||
$finalToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
|
||||
if ($finalToolFileSha256 -cne $initialToolFileSha256 -or
|
||||
$finalToolFileSha256 -cne $executedToolSha256) {
|
||||
Throw-CatalogError 'tool_source_changed'
|
||||
}
|
||||
|
||||
[string[]]$catalogHashes = @($catalogHashSet)
|
||||
[Array]::Sort($catalogHashes, [StringComparer]::Ordinal)
|
||||
$catalogSetSha256 = Get-Sha256Text(
|
||||
[string]::Join("`n", $catalogHashes))
|
||||
$toolSha256 = $executedToolSha256
|
||||
$databaseScopeFingerprint = Get-Sha256Text([string]::Join(
|
||||
"`n",
|
||||
@(
|
||||
'sqlserver-select-only-catalog-v1',
|
||||
$Server.ToLowerInvariant(),
|
||||
$actualServer.ToLowerInvariant(),
|
||||
$actualDatabase.ToLowerInvariant(),
|
||||
$effectivePrincipal.ToLowerInvariant()
|
||||
)))
|
||||
$snapshot = [ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
snapshotType = 'select_only_sqlserver_catalog_hashes'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString(
|
||||
'yyyy-MM-ddTHH:mm:ss.fffffffZ',
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
toolSha256 = $toolSha256
|
||||
databaseScopeFingerprint = $databaseScopeFingerprint
|
||||
identity = [ordered]@{
|
||||
requestedServerSha256 = Get-Sha256Text ($Server.ToLowerInvariant())
|
||||
actualServerSha256 = Get-Sha256Text ($actualServer.ToLowerInvariant())
|
||||
databaseNameSha256 = Get-Sha256Text ($actualDatabase.ToLowerInvariant())
|
||||
effectivePrincipalSha256 = Get-Sha256Text `
|
||||
($effectivePrincipal.ToLowerInvariant())
|
||||
}
|
||||
database = [ordered]@{
|
||||
sqlServerMajorVersion = $sqlServerMajorVersion
|
||||
compatibilityLevel = $compatibilityLevel
|
||||
userTableCount = $userTableCount
|
||||
userViewCount = $userViewCount
|
||||
userProcedureCount = $userProcedureCount
|
||||
userTriggerCount = $userTriggerCount
|
||||
agentWorkflowObjectCount = $agentWorkflowObjectCount
|
||||
agentWorkflowObjectsPresent = ($agentWorkflowObjectCount -eq 16)
|
||||
}
|
||||
permissionGate = [ordered]@{
|
||||
passed = $true
|
||||
checkedBeforeCatalogRead = $true
|
||||
checkedAfterCatalogRead = $true
|
||||
databaseWritePermissionCount = 0
|
||||
serverWritePermissionCount = 0
|
||||
writableObjectCount = 0
|
||||
executableObjectCount = 0
|
||||
writableSchemaCount = 0
|
||||
}
|
||||
catalog = [ordered]@{
|
||||
canonicalization = 'lower_invariant_unit_separator_v1'
|
||||
entryCount = $catalogHashes.Count
|
||||
setSha256 = $catalogSetSha256
|
||||
entrySha256 = $catalogHashes
|
||||
}
|
||||
safety = [ordered]@{
|
||||
connectionEncrypted = $true
|
||||
serverCertificateValidated = $true
|
||||
applicationIntent = 'ReadOnly'
|
||||
effectivePrincipalSelectOnly = $true
|
||||
toolSourceBytesStable = $true
|
||||
businessRowsRead = $false
|
||||
storedProceduresExecuted = $false
|
||||
writesAttempted = $false
|
||||
}
|
||||
}
|
||||
$json = ConvertTo-Json -InputObject $snapshot -Depth 8 -Compress
|
||||
Publish-RestrictedJson $outputFull $json
|
||||
$published = $true
|
||||
$snapshotSha256 = (Get-FileHash -LiteralPath $outputFull `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
[pscustomobject][ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
passed = $true
|
||||
code = 'ok'
|
||||
outputPath = $outputFull
|
||||
snapshotSha256 = $snapshotSha256
|
||||
databaseScopeFingerprint = $databaseScopeFingerprint
|
||||
catalogEntryCount = $catalogHashes.Count
|
||||
catalogSetSha256 = $catalogSetSha256
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'catalog_snapshot_failed'
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $metadataReader) { $metadataReader.Dispose() }
|
||||
if ($null -ne $metadataCommand) { $metadataCommand.Dispose() }
|
||||
if ($null -ne $connection) {
|
||||
try { $connection.Close() } catch { }
|
||||
$connection.Dispose()
|
||||
}
|
||||
if ($null -ne $connectionBuilder) {
|
||||
$connectionBuilder.Password = [string]::Empty
|
||||
$connectionBuilder.UserID = [string]::Empty
|
||||
}
|
||||
if ($outputCreated -and -not $published -and
|
||||
$null -ne $outputFull -and [IO.File]::Exists($outputFull)) {
|
||||
try { [IO.File]::Delete($outputFull) } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
[CmdletBinding(DefaultParameterSetName = 'SqlCredential')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 260)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')]
|
||||
[string]$Database,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'SqlCredential')]
|
||||
[Management.Automation.PSCredential]$Credential,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'WindowsCredential')]
|
||||
[switch]$UseWindowsAuthentication,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedProfileSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CliPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedCliSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedSignerThumbprint,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedCollectorSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SnapshotOutputPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReportOutputPath,
|
||||
|
||||
[ValidateRange(5, 60)]
|
||||
[int]$ConnectionTimeoutSeconds = 15,
|
||||
|
||||
[ValidateRange(5, 120)]
|
||||
[int]$CommandTimeoutSeconds = 60
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
throw 'lserp_select_only_profile_preflight_failed:windows_powershell_51_required'
|
||||
}
|
||||
|
||||
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$maximumProfileBytes = 1024 * 1024
|
||||
$maximumCollectorBytes = 2 * 1024 * 1024
|
||||
$maximumCliBytes = 64 * 1024 * 1024
|
||||
$maximumResponseCharacters = 4 * 1024 * 1024
|
||||
$maximumReportBytes = 1024 * 1024
|
||||
$safeSha256 = '^[a-f0-9]{64}$'
|
||||
$safeCorrelationId = '^[A-Za-z0-9_.:-]{8,128}$'
|
||||
$profileLock = $null
|
||||
$collectorLock = $null
|
||||
$cliLock = $null
|
||||
$snapshotFull = $null
|
||||
$reportFull = $null
|
||||
$snapshotCreated = $false
|
||||
$reportCreated = $false
|
||||
$reportPublished = $false
|
||||
$cliText = $null
|
||||
|
||||
function Throw-ProfilePreflightError([string]$Code) {
|
||||
throw ('lserp_select_only_profile_preflight_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_profile_preflight_failed:')) { throw }
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-RegularFile(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.PSIsContainer -or
|
||||
$item.Length -le 0 -or
|
||||
$item.Length -gt $MaximumBytes -or
|
||||
(($item.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $item.DirectoryName $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_profile_preflight_failed:')) { throw }
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
||||
[IO.File]::Exists($full) -or
|
||||
[IO.Directory]::Exists($full)) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
$directory = [IO.Path]::GetDirectoryName($full)
|
||||
if ([string]::IsNullOrWhiteSpace($directory)) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $directory $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_profile_preflight_failed:')) { throw }
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256FromOpenStream([IO.FileStream]$Stream) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Stream.Position = 0
|
||||
$hash = $algorithm.ComputeHash($Stream)
|
||||
$Stream.Position = 0
|
||||
return ([BitConverter]::ToString($hash)).Replace(
|
||||
'-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $algorithm.Dispose() }
|
||||
}
|
||||
|
||||
function Get-Sha256Bytes([byte[]]$Bytes) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString(
|
||||
$algorithm.ComputeHash($Bytes))).Replace(
|
||||
'-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $algorithm.Dispose() }
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($names.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($names -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-Boolean([object]$Value, [string]$Code) {
|
||||
if ($Value -isnot [bool]) { Throw-ProfilePreflightError $Code }
|
||||
}
|
||||
|
||||
function Assert-Sha256([object]$Value, [string]$Code) {
|
||||
if ($Value -isnot [string] -or
|
||||
([string]$Value) -cnotmatch $safeSha256) {
|
||||
Throw-ProfilePreflightError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Read-VerifiedCliEnvelope([string]$Text, [int]$ExitCode) {
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($Text) -or
|
||||
$Text.Length -gt $maximumResponseCharacters) {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
$document = $Text | ConvertFrom-Json
|
||||
if (-not (Test-ExactProperties $document `
|
||||
@('ok', 'correlationId', 'data')) -or
|
||||
$document.ok -isnot [bool] -or
|
||||
-not $document.ok -or
|
||||
([string]$document.correlationId) -cnotmatch $safeCorrelationId -or
|
||||
$null -eq $document.data -or
|
||||
$ExitCode -notin @(0, 6)) {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
$expectedDataProperties = @(
|
||||
'schemaVersion', 'verificationType', 'snapshotSha256',
|
||||
'profileSha256', 'catalogDatabaseScopeFingerprint',
|
||||
'toolSha256Verified', 'freshnessVerified',
|
||||
'permissionGateVerified', 'permissionRecheckVerified',
|
||||
'safetyVerified', 'toolSourceBytesStableVerified',
|
||||
'databaseIdentityMatches', 'databaseMetadataMatches',
|
||||
'criticalCatalogContractMatches',
|
||||
'missingCriticalCatalogEntryCount',
|
||||
'missingCriticalCatalogEntrySha256', 'catalogSetSha256',
|
||||
'onlineMetadataMatches', 'registrationReady')
|
||||
if (-not (Test-ExactProperties $document.data `
|
||||
$expectedDataProperties) -or
|
||||
[string]$document.data.schemaVersion -cne '1.1' -or
|
||||
[string]$document.data.verificationType -cne
|
||||
'select_only_catalog_snapshot') {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
foreach ($name in @(
|
||||
'toolSha256Verified', 'freshnessVerified',
|
||||
'permissionGateVerified', 'permissionRecheckVerified',
|
||||
'safetyVerified', 'toolSourceBytesStableVerified',
|
||||
'databaseIdentityMatches', 'databaseMetadataMatches',
|
||||
'criticalCatalogContractMatches', 'onlineMetadataMatches',
|
||||
'registrationReady')) {
|
||||
Assert-Boolean $document.data.$name 'cli_response_invalid'
|
||||
}
|
||||
foreach ($name in @(
|
||||
'snapshotSha256', 'profileSha256',
|
||||
'catalogDatabaseScopeFingerprint', 'catalogSetSha256')) {
|
||||
Assert-Sha256 $document.data.$name 'cli_response_invalid'
|
||||
}
|
||||
$missing = @($document.data.missingCriticalCatalogEntrySha256)
|
||||
$missingCount = 0
|
||||
if (-not [int]::TryParse(
|
||||
[string]$document.data.missingCriticalCatalogEntryCount,
|
||||
[ref]$missingCount) -or
|
||||
$missingCount -lt 0 -or $missingCount -gt 4096 -or
|
||||
$missing.Count -ne $missingCount) {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
$prior = $null
|
||||
foreach ($hash in $missing) {
|
||||
Assert-Sha256 $hash 'cli_response_invalid'
|
||||
if ($null -ne $prior -and
|
||||
[string]::CompareOrdinal($prior, [string]$hash) -ge 0) {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
$prior = [string]$hash
|
||||
}
|
||||
$expectedOnline = [bool]$document.data.databaseIdentityMatches -and
|
||||
[bool]$document.data.databaseMetadataMatches -and
|
||||
[bool]$document.data.criticalCatalogContractMatches
|
||||
if (-not $document.data.toolSha256Verified -or
|
||||
-not $document.data.freshnessVerified -or
|
||||
-not $document.data.permissionGateVerified -or
|
||||
-not $document.data.permissionRecheckVerified -or
|
||||
-not $document.data.safetyVerified -or
|
||||
-not $document.data.toolSourceBytesStableVerified -or
|
||||
$document.data.registrationReady -or
|
||||
[bool]$document.data.onlineMetadataMatches -ne $expectedOnline -or
|
||||
(($ExitCode -eq 0) -ne $expectedOnline) -or
|
||||
(($ExitCode -eq 6) -ne (-not $expectedOnline))) {
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
return $document
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_profile_preflight_failed:')) { throw }
|
||||
Throw-ProfilePreflightError 'cli_response_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function New-RestrictedFileSecurity {
|
||||
try {
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
||||
$null)
|
||||
$security = New-Object Security.AccessControl.FileSecurity
|
||||
$security.SetOwner($currentUser)
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$allow = [Security.AccessControl.AccessControlType]::Allow
|
||||
foreach ($identity in @($currentUser, $localSystem)) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$identity,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
$allow)
|
||||
[void]$security.AddAccessRule($rule)
|
||||
}
|
||||
return $security
|
||||
}
|
||||
catch {
|
||||
Throw-ProfilePreflightError 'report_acl_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-RestrictedReport([string]$Path, [object]$Report) {
|
||||
$stream = $null
|
||||
$writer = $null
|
||||
try {
|
||||
$json = ConvertTo-Json -InputObject $Report -Depth 8 -Compress
|
||||
$byteCount = $utf8.GetByteCount($json)
|
||||
if ($byteCount -le 0 -or $byteCount -gt $maximumReportBytes) {
|
||||
Throw-ProfilePreflightError 'report_size_invalid'
|
||||
}
|
||||
$security = New-RestrictedFileSecurity
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[Security.AccessControl.FileSystemRights]::Write,
|
||||
[IO.FileShare]::None,
|
||||
4096,
|
||||
[IO.FileOptions]::WriteThrough,
|
||||
$security)
|
||||
$script:reportCreated = $true
|
||||
$writer = [IO.StreamWriter]::new($stream, $utf8, 4096, $false)
|
||||
$stream = $null
|
||||
$writer.Write($json)
|
||||
$writer.Flush()
|
||||
$writer.Dispose()
|
||||
$writer = $null
|
||||
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
$acl = [IO.File]::GetAccessControl($Path)
|
||||
if ($item.Length -le 0 -or
|
||||
$item.Length -gt $maximumReportBytes -or
|
||||
(($item.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
||||
-not $acl.AreAccessRulesProtected) {
|
||||
Throw-ProfilePreflightError 'report_publish_invalid'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $writer) { $writer.Dispose() }
|
||||
if ($null -ne $stream) { $stream.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$collectorFull = Resolve-RegularFile (Join-Path $PSScriptRoot `
|
||||
'Invoke-LserpSelectOnlyCatalogSnapshot.ps1') `
|
||||
$maximumCollectorBytes 'collector_file_invalid'
|
||||
$profileFull = Resolve-RegularFile $ProfilePath `
|
||||
$maximumProfileBytes 'profile_file_invalid'
|
||||
$cliFull = Resolve-RegularFile $CliPath `
|
||||
$maximumCliBytes 'cli_file_invalid'
|
||||
if ([IO.Path]::GetFileName($cliFull) -cne 'lserp-cli.exe') {
|
||||
Throw-ProfilePreflightError 'cli_file_invalid'
|
||||
}
|
||||
$snapshotFull = Resolve-NewJsonPath $SnapshotOutputPath `
|
||||
'snapshot_output_invalid'
|
||||
$reportFull = Resolve-NewJsonPath $ReportOutputPath `
|
||||
'report_output_invalid'
|
||||
if ($snapshotFull -ceq $reportFull) {
|
||||
Throw-ProfilePreflightError 'output_paths_conflict'
|
||||
}
|
||||
|
||||
$profileLock = [IO.File]::Open(
|
||||
$profileFull, [IO.FileMode]::Open, [IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$collectorLock = [IO.File]::Open(
|
||||
$collectorFull, [IO.FileMode]::Open, [IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
$cliLock = [IO.File]::Open(
|
||||
$cliFull, [IO.FileMode]::Open, [IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
|
||||
$profileSha256 = Get-Sha256FromOpenStream $profileLock
|
||||
$collectorSha256 = Get-Sha256FromOpenStream $collectorLock
|
||||
$cliSha256 = Get-Sha256FromOpenStream $cliLock
|
||||
if ($profileSha256 -cne $ExpectedProfileSha256.ToLowerInvariant()) {
|
||||
Throw-ProfilePreflightError 'profile_hash_mismatch'
|
||||
}
|
||||
if ($collectorSha256 -cne
|
||||
$ExpectedCollectorSha256.ToLowerInvariant()) {
|
||||
Throw-ProfilePreflightError 'collector_hash_mismatch'
|
||||
}
|
||||
if ($cliSha256 -cne $ExpectedCliSha256.ToLowerInvariant()) {
|
||||
Throw-ProfilePreflightError 'cli_hash_mismatch'
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $cliFull
|
||||
$actualSignerThumbprint = if ($null -eq $signature.SignerCertificate) {
|
||||
''
|
||||
} else {
|
||||
([string]$signature.SignerCertificate.Thumbprint).Replace(
|
||||
' ', '').ToUpperInvariant()
|
||||
}
|
||||
if ($signature.Status -ne
|
||||
[Management.Automation.SignatureStatus]::Valid -or
|
||||
$actualSignerThumbprint -cne
|
||||
$ExpectedSignerThumbprint.ToUpperInvariant()) {
|
||||
Throw-ProfilePreflightError 'cli_signature_invalid'
|
||||
}
|
||||
|
||||
$collectorArguments = @{
|
||||
Server = $Server
|
||||
Database = $Database
|
||||
OutputPath = $snapshotFull
|
||||
ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds
|
||||
CommandTimeoutSeconds = $CommandTimeoutSeconds
|
||||
}
|
||||
if ($PSCmdlet.ParameterSetName -ceq 'SqlCredential') {
|
||||
$collectorArguments.Credential = $Credential
|
||||
}
|
||||
else {
|
||||
$collectorArguments.UseWindowsAuthentication = $true
|
||||
}
|
||||
& $collectorFull @collectorArguments | Out-Null
|
||||
if (-not [IO.File]::Exists($snapshotFull)) {
|
||||
Throw-ProfilePreflightError 'snapshot_not_created'
|
||||
}
|
||||
$snapshotCreated = $true
|
||||
$snapshotSha256 = (Get-FileHash -LiteralPath $snapshotFull `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$correlationId = 'catalog-preflight-' + [Guid]::NewGuid().ToString('N')
|
||||
$cliOutput = @(& $cliFull `
|
||||
'adapters' 'verify-catalog-snapshot' `
|
||||
'--input' $snapshotFull `
|
||||
'--profile' $profileFull `
|
||||
'--tool-sha256' $collectorSha256 `
|
||||
'--correlation-id' $correlationId 2>&1)
|
||||
$cliExitCode = $LASTEXITCODE
|
||||
$cliText = (($cliOutput | ForEach-Object { [string]$_ }) -join `
|
||||
[Environment]::NewLine)
|
||||
$envelope = Read-VerifiedCliEnvelope $cliText $cliExitCode
|
||||
$data = $envelope.data
|
||||
if ([string]$data.snapshotSha256 -cne $snapshotSha256 -or
|
||||
[string]$data.profileSha256 -cne $profileSha256) {
|
||||
Throw-ProfilePreflightError 'cli_artifact_binding_invalid'
|
||||
}
|
||||
if ((Get-Sha256FromOpenStream $profileLock) -cne $profileSha256 -or
|
||||
(Get-Sha256FromOpenStream $collectorLock) -cne $collectorSha256 -or
|
||||
(Get-Sha256FromOpenStream $cliLock) -cne $cliSha256) {
|
||||
Throw-ProfilePreflightError 'trusted_input_changed'
|
||||
}
|
||||
|
||||
$passed = [bool]$data.onlineMetadataMatches
|
||||
$report = [ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
reportType = 'select_only_profile_preflight'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString(
|
||||
'yyyy-MM-ddTHH:mm:ss.fffffffZ',
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
passed = $passed
|
||||
code = if ($passed) { 'ok' } else { 'catalog_metadata_mismatch' }
|
||||
snapshotSha256 = $snapshotSha256
|
||||
profileSha256 = $profileSha256
|
||||
collectorSha256 = $collectorSha256
|
||||
cliSha256 = $cliSha256
|
||||
cliSignerThumbprint = $actualSignerThumbprint
|
||||
cliResponseSha256 = Get-Sha256Bytes ($utf8.GetBytes($cliText))
|
||||
catalogDatabaseScopeFingerprint = `
|
||||
[string]$data.catalogDatabaseScopeFingerprint
|
||||
permissionGateVerified = [bool]$data.permissionGateVerified
|
||||
permissionRecheckVerified = [bool]$data.permissionRecheckVerified
|
||||
safetyVerified = [bool]$data.safetyVerified
|
||||
toolSourceBytesStableVerified = `
|
||||
[bool]$data.toolSourceBytesStableVerified
|
||||
databaseIdentityMatches = [bool]$data.databaseIdentityMatches
|
||||
databaseMetadataMatches = [bool]$data.databaseMetadataMatches
|
||||
criticalCatalogContractMatches = `
|
||||
[bool]$data.criticalCatalogContractMatches
|
||||
missingCriticalCatalogEntryCount = `
|
||||
[int]$data.missingCriticalCatalogEntryCount
|
||||
missingCriticalCatalogEntrySha256 = `
|
||||
@($data.missingCriticalCatalogEntrySha256)
|
||||
databaseSafety = [ordered]@{
|
||||
applicationIntent = 'ReadOnly'
|
||||
effectivePrincipalSelectOnly = $true
|
||||
businessRowsRead = $false
|
||||
storedProceduresExecuted = $false
|
||||
writesAttempted = $false
|
||||
}
|
||||
registrationReady = $false
|
||||
note = '本报告只证明 SELECT-only 目录与画像的当前匹配状态,不启用任何业务写命令。'
|
||||
}
|
||||
Publish-RestrictedReport $reportFull $report
|
||||
$reportPublished = $true
|
||||
[pscustomobject][ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
passed = $passed
|
||||
code = [string]$report.code
|
||||
snapshotSha256 = $snapshotSha256
|
||||
reportSha256 = (Get-FileHash -LiteralPath $reportFull `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
reportPath = $reportFull
|
||||
registrationReady = $false
|
||||
}
|
||||
if (-not $passed) {
|
||||
Throw-ProfilePreflightError 'catalog_metadata_mismatch'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_profile_preflight_failed:')) { throw }
|
||||
Throw-ProfilePreflightError 'profile_preflight_failed'
|
||||
}
|
||||
finally {
|
||||
$cliText = $null
|
||||
if ($null -ne $cliLock) { $cliLock.Dispose() }
|
||||
if ($null -ne $collectorLock) { $collectorLock.Dispose() }
|
||||
if ($null -ne $profileLock) { $profileLock.Dispose() }
|
||||
if (-not $reportPublished) {
|
||||
if ($reportCreated -and $null -ne $reportFull -and
|
||||
[IO.File]::Exists($reportFull)) {
|
||||
try { [IO.File]::Delete($reportFull) } catch { }
|
||||
}
|
||||
if ($snapshotCreated -and $null -ne $snapshotFull -and
|
||||
[IO.File]::Exists($snapshotFull)) {
|
||||
try { [IO.File]::Delete($snapshotFull) } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
#requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$AccountBook,
|
||||
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$SubSystemId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{64}$')]
|
||||
[string]$DatabaseScopeFingerprint,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')]
|
||||
[string]$AdapterId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
|
||||
[string]$AdapterVersion,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')]
|
||||
[string]$EvidenceId,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$ModulesPath,
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$ValidatedBy,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')]
|
||||
[string]$CertificateThumbprint,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
[ValidateRange(1, 366)][int]$ValidDays = 90,
|
||||
|
||||
[Parameter(Mandatory = $true)][switch]$CustomerConfigurationValidated,
|
||||
[Parameter(Mandatory = $true)][switch]$RecordResolutionVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$SnapshotBindingVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$OptimisticConcurrencyVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$PartialUpdateVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$NativeValidationVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$ModuleHooksVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$TransactionalWriteVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$PersistentIdempotencyVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$PermissionRecheckVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$ConfigurationBindingVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$WindowsIntegrationVerified
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-SafeText([string]$Value, [int]$Maximum, [string]$Name) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value) -or
|
||||
$Value.Length -gt $Maximum -or
|
||||
$Value -cne $Value.Trim()) {
|
||||
throw "$Name is blank, padded or too long."
|
||||
}
|
||||
foreach ($character in $Value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) {
|
||||
throw "$Name contains a control character."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($actual.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($actual -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString(
|
||||
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Assert-StrictJsonText([string]$Text) {
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
$jsonReader.SupportMultipleContent = $true
|
||||
$stack = New-Object Collections.Stack
|
||||
$rootValues = 0
|
||||
try {
|
||||
while ($jsonReader.Read()) {
|
||||
$token = [string]$jsonReader.TokenType
|
||||
if ($token -eq 'Comment') {
|
||||
throw 'Modules JSON comments are forbidden.'
|
||||
}
|
||||
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
$names = if ($token -eq 'StartObject') {
|
||||
New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
}
|
||||
else { $null }
|
||||
$stack.Push([pscustomobject]@{
|
||||
Kind = if ($token -eq 'StartObject') {
|
||||
'object'
|
||||
}
|
||||
else { 'array' }
|
||||
Names = $names
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
|
||||
if ($stack.Count -eq 0) {
|
||||
throw 'Modules JSON container nesting is invalid.'
|
||||
}
|
||||
$expected = if ($token -eq 'EndObject') {
|
||||
'object'
|
||||
}
|
||||
else { 'array' }
|
||||
if ([string]$stack.Peek().Kind -cne $expected) {
|
||||
throw 'Modules JSON container nesting is invalid.'
|
||||
}
|
||||
[void]$stack.Pop()
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'PropertyName') {
|
||||
if ($stack.Count -eq 0 -or
|
||||
[string]$stack.Peek().Kind -cne 'object' -or
|
||||
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
|
||||
throw 'Modules JSON contains a duplicate or misplaced property.'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
}
|
||||
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
|
||||
throw 'Modules JSON must contain exactly one complete root value.'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
|
||||
$insideString = $false
|
||||
$escaped = $false
|
||||
for ($index = 0; $index -lt $Text.Length; $index++) {
|
||||
$character = $Text[$index]
|
||||
if ($insideString) {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
if ($character -eq '"') { $insideString = $false }
|
||||
continue
|
||||
}
|
||||
if ($character -eq '"') { $insideString = $true; continue }
|
||||
if ($character -ne ',') { continue }
|
||||
$next = $index + 1
|
||||
while ($next -lt $Text.Length -and
|
||||
[char]::IsWhiteSpace($Text[$next])) { $next++ }
|
||||
if ($next -lt $Text.Length -and
|
||||
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
|
||||
throw 'Modules JSON trailing commas are forbidden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-StrictBaseModuleList([string]$Path) {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) {
|
||||
throw "Modules file does not exist: $full"
|
||||
}
|
||||
$info = New-Object IO.FileInfo($full)
|
||||
if ($info.Length -le 0 -or $info.Length -gt 256KB -or
|
||||
(($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw 'Modules file must be a non-empty ordinary file no larger than 256 KiB.'
|
||||
}
|
||||
$utf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$bytes = [IO.File]::ReadAllBytes($full)
|
||||
if ($bytes.Length -ne $info.Length) {
|
||||
throw 'Modules file changed while it was being read.'
|
||||
}
|
||||
try {
|
||||
$rawText = $utf8.GetString($bytes)
|
||||
Assert-StrictJsonText $rawText
|
||||
$document = ($rawText | ConvertFrom-Json)
|
||||
}
|
||||
catch { throw 'Modules file is not valid strict UTF-8 JSON.' }
|
||||
if (-not (Test-ExactProperties $document @('schemaVersion', 'modules')) -or
|
||||
[string]$document.schemaVersion -cne '1.0') {
|
||||
throw 'Modules file must contain only schemaVersion=1.0 and modules.'
|
||||
}
|
||||
$items = @($document.modules)
|
||||
if ($items.Count -lt 1 -or $items.Count -gt 256) {
|
||||
throw 'Modules list must contain between 1 and 256 entries.'
|
||||
}
|
||||
$seen = New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
$result = New-Object Collections.Generic.List[object]
|
||||
foreach ($item in $items) {
|
||||
if (-not (Test-ExactProperties $item `
|
||||
@('moduleCode', 'moduleKind', 'configurationFingerprint',
|
||||
'nativeSaveFamily',
|
||||
'nativeExecutionProfileFingerprint'))) {
|
||||
throw 'Every module must contain the exact configuration and native execution profile fields.'
|
||||
}
|
||||
$moduleCode = [string]$item.moduleCode
|
||||
$configuration = [string]$item.configurationFingerprint
|
||||
$nativeSaveFamily = [string]$item.nativeSaveFamily
|
||||
$nativeExecution = [string]$item.nativeExecutionProfileFingerprint
|
||||
if ($moduleCode -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
|
||||
-not $seen.Add($moduleCode) -or
|
||||
[string]$item.moduleKind -cne 'base' -or
|
||||
$configuration -cnotmatch '^[0-9a-f]{64}$' -or
|
||||
$nativeSaveFamily -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
|
||||
$nativeExecution -cnotmatch '^[0-9a-f]{64}$') {
|
||||
throw 'Dynamic update accepts only unique base modules with exact configuration and native execution hashes.'
|
||||
}
|
||||
$result.Add([ordered]@{
|
||||
moduleCode = $moduleCode
|
||||
moduleKind = 'base'
|
||||
configurationFingerprint = $configuration
|
||||
nativeSaveFamily = $nativeSaveFamily
|
||||
nativeExecutionProfileFingerprint = $nativeExecution
|
||||
})
|
||||
}
|
||||
return $result.ToArray()
|
||||
}
|
||||
|
||||
function Find-SigningCertificate([string]$Thumbprint) {
|
||||
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
if ($normalized -cnotmatch '^[A-F0-9]{40}$') {
|
||||
throw 'Certificate thumbprint is invalid.'
|
||||
}
|
||||
foreach ($location in @('CurrentUser', 'LocalMachine')) {
|
||||
$path = "Cert:\$location\TrustedPeople\$normalized"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$certificate = Get-Item -LiteralPath $path
|
||||
if (-not $certificate.HasPrivateKey) {
|
||||
throw "TrustedPeople certificate has no private key: $normalized"
|
||||
}
|
||||
$now = Get-Date
|
||||
if ($now -lt $certificate.NotBefore -or
|
||||
$now -gt $certificate.NotAfter) {
|
||||
throw "TrustedPeople certificate is not currently valid: $normalized"
|
||||
}
|
||||
return $certificate
|
||||
}
|
||||
}
|
||||
throw "Certificate not found in CurrentUser/LocalMachine TrustedPeople: $normalized"
|
||||
}
|
||||
|
||||
$confirmations = @(
|
||||
$CustomerConfigurationValidated,
|
||||
$RecordResolutionVerified,
|
||||
$SnapshotBindingVerified,
|
||||
$OptimisticConcurrencyVerified,
|
||||
$PartialUpdateVerified,
|
||||
$NativeValidationVerified,
|
||||
$ModuleHooksVerified,
|
||||
$TransactionalWriteVerified,
|
||||
$PersistentIdempotencyVerified,
|
||||
$PermissionRecheckVerified,
|
||||
$ConfigurationBindingVerified,
|
||||
$WindowsIntegrationVerified)
|
||||
foreach ($confirmation in $confirmations) {
|
||||
if (-not $confirmation.IsPresent) {
|
||||
throw 'All twelve update acceptance confirmations must be explicitly supplied.'
|
||||
}
|
||||
}
|
||||
Assert-SafeText $AccountBook 128 'AccountBook'
|
||||
Assert-SafeText $SubSystemId 128 'SubSystemId'
|
||||
Assert-SafeText $ValidatedBy 128 'ValidatedBy'
|
||||
|
||||
[object[]]$modules = @(Read-StrictBaseModuleList $ModulesPath)
|
||||
$thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
$issuedAt = [DateTime]::UtcNow
|
||||
$expiresAt = $issuedAt.AddDays($ValidDays)
|
||||
$content = [ordered]@{
|
||||
packageType = 'dynamic_module_update_acceptance'
|
||||
adapterId = $AdapterId
|
||||
adapterVersion = $AdapterVersion
|
||||
evidenceId = $EvidenceId
|
||||
erpScope = [ordered]@{
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
databaseScopeFingerprint = $DatabaseScopeFingerprint
|
||||
}
|
||||
modules = $modules
|
||||
requirements = [ordered]@{
|
||||
customerConfigurationValidated = $true
|
||||
recordResolutionVerified = $true
|
||||
snapshotBindingVerified = $true
|
||||
optimisticConcurrencyVerified = $true
|
||||
partialUpdateVerified = $true
|
||||
nativeValidationVerified = $true
|
||||
moduleHooksVerified = $true
|
||||
transactionalWriteVerified = $true
|
||||
persistentIdempotencyVerified = $true
|
||||
permissionRecheckVerified = $true
|
||||
configurationBindingVerified = $true
|
||||
windowsIntegrationVerified = $true
|
||||
}
|
||||
issuedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
validatedBy = $ValidatedBy
|
||||
note = '客户已在当前 Windows ERP、SQL Server 和精确低代码配置上完成基础档案并发修改验收。'
|
||||
}
|
||||
|
||||
$utf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$canonical = $content | ConvertTo-Json -Compress -Depth 10
|
||||
$contentBytes = $utf8.GetBytes($canonical)
|
||||
$contentSha256 = Get-Sha256Hex $contentBytes
|
||||
$certificate = Find-SigningCertificate $thumbprint
|
||||
$rsa = $certificate.PrivateKey -as `
|
||||
[Security.Cryptography.RSACryptoServiceProvider]
|
||||
if ($null -eq $rsa) {
|
||||
throw 'Signing certificate must expose an RSA CSP private key for the .NET Framework 4.0 client.'
|
||||
}
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $digest = $sha.ComputeHash($contentBytes) }
|
||||
finally { $sha.Dispose() }
|
||||
$signature = $rsa.SignHash(
|
||||
$digest,
|
||||
[Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
|
||||
|
||||
$package = [ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
contentSha256 = $contentSha256
|
||||
signatureAlgorithm = 'rsa-sha256'
|
||||
certificateThumbprint = $thumbprint
|
||||
signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
content = $content
|
||||
}
|
||||
$body = $utf8.GetBytes(($package | ConvertTo-Json -Depth 10))
|
||||
$fullOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
$directory = [IO.Path]::GetDirectoryName($fullOutput)
|
||||
if ([string]::IsNullOrWhiteSpace($directory) -or
|
||||
-not [IO.Directory]::Exists($directory)) {
|
||||
throw "Output directory does not exist: $directory"
|
||||
}
|
||||
$stream = [IO.File]::Open(
|
||||
$fullOutput,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None)
|
||||
try {
|
||||
$stream.Write($body, 0, $body.Length)
|
||||
$stream.Flush()
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
|
||||
[ordered]@{
|
||||
outputFile = $fullOutput
|
||||
packageType = 'dynamic_module_update_acceptance'
|
||||
moduleCount = $modules.Count
|
||||
evidenceId = $EvidenceId
|
||||
evidenceSha256 = $contentSha256
|
||||
validatedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
certificateThumbprint = $thumbprint
|
||||
readinessProcedure = 'dbo.p_lserp_agent_module_update_acceptance_v2'
|
||||
runtimeEnvironment = [ordered]@{
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_ENABLED = '1'
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_READINESS_SHA256 = $contentSha256
|
||||
LSERP_DYNAMIC_MODULE_UPDATE_ACCEPTANCE_PATH = $fullOutput
|
||||
}
|
||||
nextStep = '由客户 DBA 使用每个基础档案的精确配置指纹、本输出 evidenceSha256 和 validatedAtUtc 调用更新验收过程;禁止授权给 ERP 日常账号。'
|
||||
} | ConvertTo-Json -Depth 6
|
||||
@@ -0,0 +1,356 @@
|
||||
#requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$AccountBook,
|
||||
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$SubSystemId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{64}$')]
|
||||
[string]$DatabaseScopeFingerprint,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')]
|
||||
[string]$AdapterId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
|
||||
[string]$AdapterVersion,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')]
|
||||
[string]$EvidenceId,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$ModulesPath,
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)]
|
||||
[string]$ValidatedBy,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')]
|
||||
[string]$CertificateThumbprint,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
[ValidateRange(1, 366)][int]$ValidDays = 90,
|
||||
|
||||
[Parameter(Mandatory = $true)][switch]$CustomerConfigurationValidated,
|
||||
[Parameter(Mandatory = $true)][switch]$NativeValidationVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$ServerDefaultsVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$ModuleHooksVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$TransactionalWriteVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$PersistentIdempotencyVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$PermissionRecheckVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$ConfigurationBindingVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$WindowsIntegrationVerified
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-SafeText([string]$Value, [int]$Maximum, [string]$Name) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt $Maximum) {
|
||||
throw "$Name is blank or too long."
|
||||
}
|
||||
foreach ($character in $Value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) {
|
||||
throw "$Name contains a control character."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$actual = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($actual.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($actual -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$hex = [BitConverter]::ToString($sha.ComputeHash($Bytes))
|
||||
return $hex.Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Assert-StrictJsonText([string]$Text) {
|
||||
# ConvertFrom-Json 在某些 Windows PowerShell 版本会容忍重复键或
|
||||
# 尾随逗号。签发前先用 PowerShell 自带 JSON.NET 的流式令牌
|
||||
# 读取器建立独立失败关闭门禁。
|
||||
$textReader = New-Object IO.StringReader($Text)
|
||||
$jsonReader = New-Object Newtonsoft.Json.JsonTextReader($textReader)
|
||||
$jsonReader.DateParseHandling = [Newtonsoft.Json.DateParseHandling]::None
|
||||
$jsonReader.SupportMultipleContent = $true
|
||||
$stack = New-Object Collections.Stack
|
||||
$rootValues = 0
|
||||
try {
|
||||
while ($jsonReader.Read()) {
|
||||
$token = [string]$jsonReader.TokenType
|
||||
if ($token -eq 'Comment') {
|
||||
throw 'Modules JSON comments are forbidden.'
|
||||
}
|
||||
if ($token -eq 'StartObject' -or $token -eq 'StartArray') {
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
$names = if ($token -eq 'StartObject') {
|
||||
New-Object 'Collections.Generic.HashSet[string]' `
|
||||
([StringComparer]::Ordinal)
|
||||
}
|
||||
else { $null }
|
||||
$stack.Push([pscustomobject]@{
|
||||
Kind = if ($token -eq 'StartObject') { 'object' } else { 'array' }
|
||||
Names = $names
|
||||
})
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'EndObject' -or $token -eq 'EndArray') {
|
||||
if ($stack.Count -eq 0) {
|
||||
throw 'Modules JSON container nesting is invalid.'
|
||||
}
|
||||
$expected = if ($token -eq 'EndObject') { 'object' } else { 'array' }
|
||||
if ([string]$stack.Peek().Kind -cne $expected) {
|
||||
throw 'Modules JSON container nesting is invalid.'
|
||||
}
|
||||
[void]$stack.Pop()
|
||||
continue
|
||||
}
|
||||
if ($token -eq 'PropertyName') {
|
||||
if ($stack.Count -eq 0 -or
|
||||
[string]$stack.Peek().Kind -cne 'object' -or
|
||||
-not $stack.Peek().Names.Add([string]$jsonReader.Value)) {
|
||||
throw 'Modules JSON contains a duplicate or misplaced property.'
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ($stack.Count -eq 0) { $rootValues++ }
|
||||
}
|
||||
if ($stack.Count -ne 0 -or $rootValues -ne 1) {
|
||||
throw 'Modules JSON must contain exactly one complete root value.'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$jsonReader.Close()
|
||||
$textReader.Dispose()
|
||||
}
|
||||
|
||||
$insideString = $false
|
||||
$escaped = $false
|
||||
for ($index = 0; $index -lt $Text.Length; $index++) {
|
||||
$character = $Text[$index]
|
||||
if ($insideString) {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
if ($character -eq '"') { $insideString = $false }
|
||||
continue
|
||||
}
|
||||
if ($character -eq '"') { $insideString = $true; continue }
|
||||
if ($character -ne ',') { continue }
|
||||
$next = $index + 1
|
||||
while ($next -lt $Text.Length -and
|
||||
[char]::IsWhiteSpace($Text[$next])) { $next++ }
|
||||
if ($next -lt $Text.Length -and
|
||||
($Text[$next] -eq '}' -or $Text[$next] -eq ']')) {
|
||||
throw 'Modules JSON trailing commas are forbidden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-StrictModuleList([string]$Path) {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) {
|
||||
throw "Modules file does not exist: $full"
|
||||
}
|
||||
$info = New-Object IO.FileInfo($full)
|
||||
if ($info.Length -le 0 -or $info.Length -gt 256KB -or
|
||||
(($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw 'Modules file must be a non-empty ordinary file no larger than 256 KiB.'
|
||||
}
|
||||
$utf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$bytes = [IO.File]::ReadAllBytes($full)
|
||||
if ($bytes.Length -ne $info.Length) {
|
||||
throw 'Modules file changed while it was being read.'
|
||||
}
|
||||
$rawText = $utf8.GetString($bytes)
|
||||
try {
|
||||
Assert-StrictJsonText $rawText
|
||||
$document = ($rawText | ConvertFrom-Json)
|
||||
}
|
||||
catch { throw 'Modules file is not valid strict UTF-8 JSON.' }
|
||||
if (-not (Test-ExactProperties $document @('schemaVersion', 'modules')) -or
|
||||
[string]$document.schemaVersion -cne '1.0') {
|
||||
throw 'Modules file must contain only schemaVersion=1.0 and modules.'
|
||||
}
|
||||
$items = @($document.modules)
|
||||
if ($items.Count -lt 1 -or $items.Count -gt 256) {
|
||||
throw 'Modules list must contain between 1 and 256 entries.'
|
||||
}
|
||||
$seen = [Collections.Generic.HashSet[string]]::new(
|
||||
[StringComparer]::Ordinal)
|
||||
$result = New-Object Collections.Generic.List[object]
|
||||
foreach ($item in $items) {
|
||||
if (-not (Test-ExactProperties $item `
|
||||
@('moduleCode', 'moduleKind', 'configurationFingerprint',
|
||||
'nativeSaveFamily',
|
||||
'nativeExecutionProfileFingerprint'))) {
|
||||
throw 'Every module must contain the exact configuration and native execution profile fields.'
|
||||
}
|
||||
$moduleCode = [string]$item.moduleCode
|
||||
$moduleKind = [string]$item.moduleKind
|
||||
$configuration = [string]$item.configurationFingerprint
|
||||
$nativeSaveFamily = [string]$item.nativeSaveFamily
|
||||
$nativeExecution = [string]$item.nativeExecutionProfileFingerprint
|
||||
if ($moduleCode -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
|
||||
-not $seen.Add($moduleCode) -or
|
||||
$moduleKind -cnotin @('base', 'bill') -or
|
||||
$configuration -cnotmatch '^[0-9a-f]{64}$' -or
|
||||
$nativeSaveFamily -cnotmatch '^[A-Za-z0-9_.:-]{1,64}$' -or
|
||||
$nativeExecution -cnotmatch '^[0-9a-f]{64}$') {
|
||||
throw 'A module code, kind, configuration hash or native execution profile is invalid.'
|
||||
}
|
||||
$result.Add([ordered]@{
|
||||
moduleCode = $moduleCode
|
||||
moduleKind = $moduleKind
|
||||
configurationFingerprint = $configuration
|
||||
nativeSaveFamily = $nativeSaveFamily
|
||||
nativeExecutionProfileFingerprint = $nativeExecution
|
||||
})
|
||||
}
|
||||
return $result.ToArray()
|
||||
}
|
||||
|
||||
function Find-SigningCertificate([string]$Thumbprint) {
|
||||
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
if ($normalized -cnotmatch '^[A-F0-9]{40}$') {
|
||||
throw 'Certificate thumbprint is invalid.'
|
||||
}
|
||||
foreach ($location in @('CurrentUser', 'LocalMachine')) {
|
||||
$path = "Cert:\$location\TrustedPeople\$normalized"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$certificate = Get-Item -LiteralPath $path
|
||||
if (-not $certificate.HasPrivateKey) {
|
||||
throw "TrustedPeople certificate has no private key: $normalized"
|
||||
}
|
||||
$now = Get-Date
|
||||
if ($now -lt $certificate.NotBefore -or $now -gt $certificate.NotAfter) {
|
||||
throw "TrustedPeople certificate is not currently valid: $normalized"
|
||||
}
|
||||
return $certificate
|
||||
}
|
||||
}
|
||||
throw "Certificate not found in CurrentUser/LocalMachine TrustedPeople: $normalized"
|
||||
}
|
||||
|
||||
foreach ($confirmation in @(
|
||||
$CustomerConfigurationValidated,
|
||||
$NativeValidationVerified,
|
||||
$ServerDefaultsVerified,
|
||||
$ModuleHooksVerified,
|
||||
$TransactionalWriteVerified,
|
||||
$PersistentIdempotencyVerified,
|
||||
$PermissionRecheckVerified,
|
||||
$ConfigurationBindingVerified,
|
||||
$WindowsIntegrationVerified)) {
|
||||
if (-not $confirmation.IsPresent) {
|
||||
throw 'All nine acceptance confirmations must be explicitly supplied.'
|
||||
}
|
||||
}
|
||||
Assert-SafeText $AccountBook 128 'AccountBook'
|
||||
Assert-SafeText $SubSystemId 128 'SubSystemId'
|
||||
Assert-SafeText $ValidatedBy 128 'ValidatedBy'
|
||||
|
||||
[object[]]$modules = @(Read-StrictModuleList $ModulesPath)
|
||||
$thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
$issuedAt = [DateTime]::UtcNow
|
||||
$expiresAt = $issuedAt.AddDays($ValidDays)
|
||||
$content = [ordered]@{
|
||||
packageType = 'dynamic_module_write_acceptance'
|
||||
adapterId = $AdapterId
|
||||
adapterVersion = $AdapterVersion
|
||||
evidenceId = $EvidenceId
|
||||
erpScope = [ordered]@{
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
databaseScopeFingerprint = $DatabaseScopeFingerprint
|
||||
}
|
||||
modules = $modules
|
||||
requirements = [ordered]@{
|
||||
customerConfigurationValidated = $true
|
||||
nativeValidationVerified = $true
|
||||
serverDefaultsVerified = $true
|
||||
moduleHooksVerified = $true
|
||||
transactionalWriteVerified = $true
|
||||
persistentIdempotencyVerified = $true
|
||||
permissionRecheckVerified = $true
|
||||
configurationBindingVerified = $true
|
||||
windowsIntegrationVerified = $true
|
||||
}
|
||||
issuedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
validatedBy = $ValidatedBy
|
||||
note = '客户已在当前 Windows ERP、SQL Server 和精确低代码配置上完成动态模块新增验收。'
|
||||
}
|
||||
|
||||
$utf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$canonical = $content | ConvertTo-Json -Compress -Depth 10
|
||||
$contentBytes = $utf8.GetBytes($canonical)
|
||||
$contentSha256 = Get-Sha256Hex $contentBytes
|
||||
$certificate = Find-SigningCertificate $thumbprint
|
||||
$rsa = $certificate.PrivateKey -as `
|
||||
[Security.Cryptography.RSACryptoServiceProvider]
|
||||
if ($null -eq $rsa) {
|
||||
throw 'Signing certificate must expose an RSA CSP private key for the .NET Framework 4.0 client.'
|
||||
}
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $digest = $sha.ComputeHash($contentBytes) }
|
||||
finally { $sha.Dispose() }
|
||||
$signature = $rsa.SignHash(
|
||||
$digest,
|
||||
[Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
|
||||
|
||||
$package = [ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
contentSha256 = $contentSha256
|
||||
signatureAlgorithm = 'rsa-sha256'
|
||||
certificateThumbprint = $thumbprint
|
||||
signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
content = $content
|
||||
}
|
||||
$body = $utf8.GetBytes(($package | ConvertTo-Json -Depth 10))
|
||||
$fullOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
$directory = [IO.Path]::GetDirectoryName($fullOutput)
|
||||
if ([string]::IsNullOrWhiteSpace($directory) -or
|
||||
-not [IO.Directory]::Exists($directory)) {
|
||||
throw "Output directory does not exist: $directory"
|
||||
}
|
||||
$stream = [IO.File]::Open(
|
||||
$fullOutput,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None)
|
||||
try {
|
||||
$stream.Write($body, 0, $body.Length)
|
||||
$stream.Flush()
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
|
||||
[ordered]@{
|
||||
outputFile = $fullOutput
|
||||
packageType = 'dynamic_module_write_acceptance'
|
||||
moduleCount = $modules.Count
|
||||
evidenceId = $EvidenceId
|
||||
evidenceSha256 = $contentSha256
|
||||
validatedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
certificateThumbprint = $thumbprint
|
||||
readinessProcedure = 'dbo.p_lserp_agent_module_write_acceptance_v2'
|
||||
runtimeEnvironment = [ordered]@{
|
||||
LSERP_DYNAMIC_MODULE_WRITE_ENABLED = '1'
|
||||
LSERP_DYNAMIC_MODULE_WRITE_READINESS_SHA256 = $contentSha256
|
||||
LSERP_DYNAMIC_MODULE_WRITE_ACCEPTANCE_PATH = $fullOutput
|
||||
}
|
||||
nextStep = '由客户 DBA 使用每个模块的精确配置指纹、本输出 evidenceSha256 和 validatedAtUtc 调用验收过程;禁止授权给 ERP 日常账号。'
|
||||
} | ConvertTo-Json -Depth 6
|
||||
@@ -0,0 +1,675 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('purchase', 'leave')]
|
||||
[string]$Workflow,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
|
||||
[string]$ModuleCode,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$AccountBook,
|
||||
[Parameter(Mandatory = $true)][string]$SubSystemId,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[a-z0-9_.-]{1,128}$')][string]$AdapterId,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[a-z0-9_.-]{1,128}$')][string]$AdapterVersion,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')][string]$EvidenceId,
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile,
|
||||
[Parameter(Mandatory = $true)][string]$CustomerProfileFile,
|
||||
[Parameter(Mandatory = $true)][string]$FieldMappingEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$ReadContractEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$WriteIntegrationEvidence,
|
||||
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$ErpUser,
|
||||
[Parameter(Mandatory = $true)][System.Security.SecureString]$ErpPassword,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$ExpectedSourceCommit,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$ExpectedPackageSha256,
|
||||
[Parameter(Mandatory = $true)][string]$ValidatedBy,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')][string]$CertificateThumbprint,
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
[ValidateRange(1, 366)][int]$ValidDays = 90
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$hash = $sha.ComputeHash($Bytes)
|
||||
return ([System.BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-FileSha256(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Label) {
|
||||
$full = [System.IO.Path]::GetFullPath($Path)
|
||||
if (-not [System.IO.File]::Exists($full)) {
|
||||
throw "$Label file not found: $full"
|
||||
}
|
||||
$info = New-Object System.IO.FileInfo($full)
|
||||
if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or
|
||||
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw "$Label must be a non-empty regular file no larger than $MaximumBytes bytes: $full"
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($full)
|
||||
if ($bytes.Length -le 0 -or $bytes.Length -gt $MaximumBytes) {
|
||||
throw "$Label changed size while it was read: $full"
|
||||
}
|
||||
return Get-Sha256Hex $bytes
|
||||
}
|
||||
|
||||
function Open-InputLock(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$Label) {
|
||||
$full = [System.IO.Path]::GetFullPath($Path)
|
||||
if (-not [System.IO.File]::Exists($full)) {
|
||||
throw "$Label file not found: $full"
|
||||
}
|
||||
$info = New-Object System.IO.FileInfo($full)
|
||||
if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or
|
||||
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw "$Label must be a non-empty ordinary file within the size limit: $full"
|
||||
}
|
||||
$stream = [System.IO.File]::Open(
|
||||
$full,
|
||||
[System.IO.FileMode]::Open,
|
||||
[System.IO.FileAccess]::Read,
|
||||
[System.IO.FileShare]::Read)
|
||||
if ($stream.Length -le 0 -or $stream.Length -gt $MaximumBytes) {
|
||||
$stream.Dispose()
|
||||
throw "$Label changed size while it was locked: $full"
|
||||
}
|
||||
return $stream
|
||||
}
|
||||
|
||||
function Find-SigningCertificate([string]$Thumbprint) {
|
||||
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
foreach ($location in @('CurrentUser', 'LocalMachine')) {
|
||||
$path = "Cert:\$location\TrustedPeople\$normalized"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$certificate = Get-Item -LiteralPath $path
|
||||
if (-not $certificate.HasPrivateKey) {
|
||||
throw "TrustedPeople certificate has no private key: $normalized"
|
||||
}
|
||||
if ((Get-Date) -lt $certificate.NotBefore -or (Get-Date) -gt $certificate.NotAfter) {
|
||||
throw "TrustedPeople certificate is not currently valid: $normalized"
|
||||
}
|
||||
return $certificate
|
||||
}
|
||||
}
|
||||
throw "Certificate not found in CurrentUser/LocalMachine TrustedPeople: $normalized"
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($names.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($names -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-AuthenticatedCli(
|
||||
[string]$CliPath,
|
||||
[string[]]$Arguments,
|
||||
[string]$User,
|
||||
[System.Security.SecureString]$Password,
|
||||
[string]$Ledger,
|
||||
[string]$Subsystem) {
|
||||
$pointer = [IntPtr]::Zero
|
||||
$plainText = $null
|
||||
try {
|
||||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
|
||||
$plainText = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
||||
$allArguments = @($Arguments) + @(
|
||||
'--user', $User,
|
||||
'--password-stdin',
|
||||
'--ledger', $Ledger,
|
||||
'--subsystem', $Subsystem
|
||||
)
|
||||
$output = @($plainText | & $CliPath @allArguments 2>&1)
|
||||
$exitCode = $LASTEXITCODE
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $exitCode
|
||||
Text = (($output | ForEach-Object { [string]$_ }) -join `
|
||||
[Environment]::NewLine)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$plainText = $null
|
||||
if ($pointer -ne [IntPtr]::Zero) {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-FieldMappingVerifier(
|
||||
[string]$CliPath,
|
||||
[string]$MappingPath,
|
||||
[string]$ExpectedWorkflow,
|
||||
[string]$ExpectedModule,
|
||||
[string]$ExpectedAccountBook,
|
||||
[string]$ExpectedSubsystem,
|
||||
[string]$User,
|
||||
[System.Security.SecureString]$Password) {
|
||||
$result = Invoke-AuthenticatedCli `
|
||||
$CliPath `
|
||||
@('adapters', 'validate-fields', $ExpectedWorkflow, '--input',
|
||||
[System.IO.Path]::GetFullPath($MappingPath)) `
|
||||
$User $Password $ExpectedAccountBook $ExpectedSubsystem
|
||||
if ($result.ExitCode -ne 0) {
|
||||
throw 'Field mapping failed live ERP metadata validation.'
|
||||
}
|
||||
try { $response = $result.Text | ConvertFrom-Json }
|
||||
catch { throw 'Field mapping verifier did not return valid JSON.' }
|
||||
$expectedData = @(
|
||||
'workflow', 'moduleCode', 'moduleKind', 'erpScope', 'fieldMapReady',
|
||||
'registrationReady', 'issues', 'requiredRuntimeEvidence', 'storage', 'note'
|
||||
)
|
||||
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
||||
$response.ok -ne $true -or
|
||||
-not (Test-ExactProperties $response.data $expectedData) -or
|
||||
-not (Test-ExactProperties $response.data.erpScope `
|
||||
@('accountBook', 'subSystemId', 'validatedByUserId')) -or
|
||||
$response.data.workflow -cne $ExpectedWorkflow -or
|
||||
$response.data.moduleCode -cne $ExpectedModule -or
|
||||
$response.data.erpScope.accountBook -ne $ExpectedAccountBook -or
|
||||
$response.data.erpScope.subSystemId -ne $ExpectedSubsystem -or
|
||||
[string]::IsNullOrWhiteSpace(
|
||||
[string]$response.data.erpScope.validatedByUserId) -or
|
||||
$response.data.fieldMapReady -ne $true -or
|
||||
@($response.data.issues).Count -ne 0 -or
|
||||
$response.data.registrationReady -ne $false) {
|
||||
throw 'Field mapping verifier response is not ready or not bound to this ERP scope.'
|
||||
}
|
||||
return $response.data
|
||||
}
|
||||
|
||||
function Invoke-ReadContractEvidenceVerifier(
|
||||
[string]$CliPath,
|
||||
[string]$EvidencePath,
|
||||
[string]$ExpectedWorkflow,
|
||||
[string]$ExpectedModule,
|
||||
[string]$ExpectedAccountBook,
|
||||
[string]$ExpectedSubsystem) {
|
||||
$output = @(& $CliPath `
|
||||
'adapters' 'verify-contract-evidence' `
|
||||
'--input' ([System.IO.Path]::GetFullPath($EvidencePath)) `
|
||||
'--workflow' $ExpectedWorkflow `
|
||||
'--module' $ExpectedModule `
|
||||
'--account-book' $ExpectedAccountBook `
|
||||
'--subsystem' $ExpectedSubsystem 2>&1)
|
||||
$exitCode = $LASTEXITCODE
|
||||
$text = (($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
||||
if ($exitCode -ne 0) {
|
||||
throw 'Read contract evidence failed strict CLI verification.'
|
||||
}
|
||||
try { $response = $text | ConvertFrom-Json }
|
||||
catch { throw 'Read contract verifier did not return valid JSON.' }
|
||||
$expectedData = @(
|
||||
'packageType', 'schemaVersion', 'workflow', 'moduleCode',
|
||||
'contentSha256', 'integrityValid', 'verified', 'signatureVerified',
|
||||
'registrationReady', 'note', 'scopeBindingVerified'
|
||||
)
|
||||
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
||||
$response.ok -ne $true -or
|
||||
-not (Test-ExactProperties $response.data $expectedData) -or
|
||||
$response.data.packageType -ne 'workflow_read_contract_evidence' -or
|
||||
$response.data.schemaVersion -ne '1.0' -or
|
||||
$response.data.workflow -cne $ExpectedWorkflow -or
|
||||
$response.data.moduleCode -cne $ExpectedModule -or
|
||||
$response.data.integrityValid -ne $true -or
|
||||
$response.data.verified -ne $true -or
|
||||
$response.data.scopeBindingVerified -ne $true -or
|
||||
$response.data.signatureVerified -ne $false -or
|
||||
$response.data.registrationReady -ne $false) {
|
||||
throw 'Read contract evidence is not verified or not bound to this acceptance scope.'
|
||||
}
|
||||
return $response.data
|
||||
}
|
||||
|
||||
function Invoke-WriteEvidenceVerifier(
|
||||
[string]$CliPath,
|
||||
[string]$EvidencePath,
|
||||
[string]$RuntimeHash) {
|
||||
$cli = [System.IO.Path]::GetFullPath($CliPath)
|
||||
if (-not [System.IO.File]::Exists($cli)) { throw 'Verifier CLI does not exist.' }
|
||||
$info = New-Object System.IO.FileInfo($cli)
|
||||
if ($info.Length -le 0 -or $info.Length -gt 64MB -or
|
||||
(($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
||||
[System.IO.Path]::GetFileName($cli) -ne 'lserp-cli.exe') {
|
||||
throw 'Verifier CLI must be a non-empty ordinary lserp-cli.exe file no larger than 64 MB.'
|
||||
}
|
||||
$arguments = @(
|
||||
'adapters', 'verify-write-integration-evidence',
|
||||
'--input', [System.IO.Path]::GetFullPath($EvidencePath),
|
||||
'--workflow', $Workflow,
|
||||
'--module', $ModuleCode,
|
||||
'--account-book', $AccountBook,
|
||||
'--subsystem', $SubSystemId,
|
||||
'--runtime-sha256', $RuntimeHash,
|
||||
'--source-commit', $ExpectedSourceCommit.ToLowerInvariant(),
|
||||
'--package-sha256', $ExpectedPackageSha256.ToLowerInvariant()
|
||||
)
|
||||
$output = @(& $cli @arguments 2>&1)
|
||||
$exitCode = $LASTEXITCODE
|
||||
$text = (($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
||||
if ($exitCode -ne 0) { throw 'Write integration evidence failed strict CLI verification.' }
|
||||
try { $response = $text | ConvertFrom-Json }
|
||||
catch { throw 'Verifier CLI did not return valid JSON.' }
|
||||
$expectedData = @(
|
||||
'evidenceType', 'schemaVersion', 'contentSha256', 'workflow', 'moduleCode',
|
||||
'erpScope', 'sourceCommit', 'packageSha256', 'runtimeConfigurationSha256',
|
||||
'uatAuthorizationSourceSha256', 'uatAuthorizationContentSha256',
|
||||
'uatAuthorizationIdSha256',
|
||||
'environmentId', 'testedAtUtc', 'testedBy', 'caseCount', 'verified',
|
||||
'registrationReady'
|
||||
)
|
||||
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
||||
$response.ok -ne $true -or
|
||||
-not (Test-ExactProperties $response.data $expectedData) -or
|
||||
-not (Test-ExactProperties $response.data.erpScope @(
|
||||
'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256',
|
||||
'databaseScopeFingerprint', 'isAdministrator')) -or
|
||||
$response.data.evidenceType -ne 'workflow_write_integration' -or
|
||||
$response.data.schemaVersion -ne '1.5' -or
|
||||
$response.data.verified -ne $true -or
|
||||
$response.data.registrationReady -ne $false -or
|
||||
$response.data.workflow -ne $Workflow -or
|
||||
$response.data.moduleCode -ne $ModuleCode -or
|
||||
$response.data.erpScope.accountBook -ne $AccountBook -or
|
||||
$response.data.erpScope.subSystemId -ne $SubSystemId -or
|
||||
$response.data.runtimeConfigurationSha256 -ne $RuntimeHash.ToLowerInvariant() -or
|
||||
$response.data.sourceCommit -ne $ExpectedSourceCommit.ToLowerInvariant() -or
|
||||
$response.data.packageSha256 -ne $ExpectedPackageSha256.ToLowerInvariant() -or
|
||||
[int]$response.data.caseCount -ne $(if ($Workflow -eq 'purchase') { 13 } else { 19 })) {
|
||||
throw 'Verifier CLI response is not bound to this acceptance scope and artifact.'
|
||||
}
|
||||
return $response.data
|
||||
}
|
||||
|
||||
function Invoke-CustomerProfileVerifier(
|
||||
[string]$CliPath,
|
||||
[string]$ProfilePath,
|
||||
[string]$ExpectedHash,
|
||||
[string]$ExpectedWorkflow,
|
||||
[string]$ExpectedAccountBook,
|
||||
[string]$ExpectedSubsystem,
|
||||
[string]$User,
|
||||
[System.Security.SecureString]$Password) {
|
||||
$cli = [System.IO.Path]::GetFullPath($CliPath)
|
||||
$profile = [System.IO.Path]::GetFullPath($ProfilePath)
|
||||
$result = Invoke-AuthenticatedCli `
|
||||
$cli `
|
||||
@('adapters', 'revalidate-profile', '--input', $profile) `
|
||||
$User $Password $ExpectedAccountBook $ExpectedSubsystem
|
||||
if ($result.ExitCode -notin @(0, 6)) {
|
||||
throw 'Customer profile failed online catalog revalidation.'
|
||||
}
|
||||
try { $response = $result.Text | ConvertFrom-Json }
|
||||
catch { throw 'Customer profile verifier did not return valid JSON.' }
|
||||
$expectedData = @(
|
||||
'schemaVersion', 'profileType', 'profileSha256', 'profileSafetyValidated',
|
||||
'metadataQueryScope', 'onlineMetadataMatches',
|
||||
'criticalCatalogContractMatches', 'driftCodes', 'openActivationBlockerCount',
|
||||
'workflowActivation', 'activationAllowed', 'registrationReady', 'note'
|
||||
)
|
||||
$allowedNonCriticalDriftCodes = @(
|
||||
'profile_table_count_changed',
|
||||
'profile_view_count_changed',
|
||||
'profile_procedure_count_changed',
|
||||
'profile_trigger_count_changed',
|
||||
'profile_agent_object_state_changed'
|
||||
)
|
||||
$unexpectedDriftCodes = @($response.data.driftCodes | Where-Object {
|
||||
[string]$_ -notin $allowedNonCriticalDriftCodes
|
||||
})
|
||||
try {
|
||||
$strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
$profileDocument = [System.IO.File]::ReadAllText($profile, $strictUtf8) |
|
||||
ConvertFrom-Json
|
||||
}
|
||||
catch { throw 'Customer profile cannot be reread as strict UTF-8 JSON.' }
|
||||
if (@($response.data.driftCodes) -contains 'profile_agent_object_state_changed' -and
|
||||
$profileDocument.database.agentWorkflowObjectsPresent -ne $false) {
|
||||
$unexpectedDriftCodes += 'profile_agent_object_state_changed'
|
||||
}
|
||||
$selectedActivation = if ($ExpectedWorkflow -eq 'purchase') {
|
||||
$response.data.workflowActivation.purchase
|
||||
}
|
||||
else {
|
||||
$response.data.workflowActivation.leave
|
||||
}
|
||||
if (-not (Test-ExactProperties $response @('ok', 'correlationId', 'data')) -or
|
||||
$response.ok -ne $true -or
|
||||
-not (Test-ExactProperties $response.data $expectedData) -or
|
||||
-not (Test-ExactProperties $response.data.workflowActivation `
|
||||
@('purchase', 'leave')) -or
|
||||
-not (Test-ExactProperties $response.data.workflowActivation.purchase `
|
||||
@('approved', 'openBlockerCount')) -or
|
||||
-not (Test-ExactProperties $response.data.workflowActivation.leave `
|
||||
@('approved', 'openBlockerCount')) -or
|
||||
$response.data.schemaVersion -ne '1.2' -or
|
||||
$response.data.profileType -ne 'readonly_low_code_metadata_review' -or
|
||||
$response.data.profileSha256 -cne $ExpectedHash -or
|
||||
$response.data.profileSafetyValidated -ne $true -or
|
||||
$response.data.metadataQueryScope -ne 'system_catalog_only' -or
|
||||
$response.data.criticalCatalogContractMatches -ne $true -or
|
||||
$unexpectedDriftCodes.Count -ne 0 -or
|
||||
$selectedActivation.approved -ne $true -or
|
||||
[int]$selectedActivation.openBlockerCount -ne 0 -or
|
||||
$response.data.activationAllowed -ne $false -or
|
||||
$response.data.registrationReady -ne $false) {
|
||||
throw 'Customer profile verifier response has unsafe identity, compatibility, or critical catalog drift.'
|
||||
}
|
||||
return $response.data
|
||||
}
|
||||
|
||||
function Assert-ProfileResolutionBindings(
|
||||
[string]$ProfilePath,
|
||||
[string]$ExpectedWorkflow,
|
||||
[string]$ExpectedModule,
|
||||
[string]$FieldMappingHash,
|
||||
[string]$ReadContractHash,
|
||||
[string]$WriteIntegrationHash) {
|
||||
try {
|
||||
$strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
$profile = [System.IO.File]::ReadAllText(
|
||||
[System.IO.Path]::GetFullPath($ProfilePath),
|
||||
$strictUtf8) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
throw 'Customer profile resolution bindings cannot be read as strict UTF-8 JSON.'
|
||||
}
|
||||
if ($profile.schemaVersion -cne '1.2') {
|
||||
throw 'Customer profile resolution bindings require schemaVersion 1.2.'
|
||||
}
|
||||
|
||||
if ($ExpectedWorkflow -ceq 'purchase') {
|
||||
$profileModule = [string]$profile.purchaseTargetSelection.selectedModuleCode
|
||||
$blockers = @($profile.purchaseActivationBlockers)
|
||||
$expectedCodes = @(
|
||||
'purchase_currency_field_not_configured',
|
||||
'purchase_currency_crosswalk_not_approved',
|
||||
'purchase_row_scope_not_approved',
|
||||
'purchase_compat100_write_contract_not_approved',
|
||||
'purchase_windows_integration_not_verified'
|
||||
)
|
||||
}
|
||||
else {
|
||||
$profileModule = [string]$profile.modules.leave.moduleCode
|
||||
$blockers = @($profile.leaveActivationBlockers)
|
||||
$expectedCodes = @(
|
||||
'leave_flow_type_rules_stale',
|
||||
'leave_agent_schema_not_deployed',
|
||||
'leave_compat100_write_contract_not_approved',
|
||||
'leave_windows_integration_not_verified'
|
||||
)
|
||||
}
|
||||
if ($profileModule -cne $ExpectedModule) {
|
||||
throw 'Customer profile workflow module is not bound to the acceptance module.'
|
||||
}
|
||||
if ($blockers.Count -ne $expectedCodes.Count) {
|
||||
throw 'Customer profile blocker resolution set is incomplete or contains unexpected entries.'
|
||||
}
|
||||
|
||||
$seenCodes = @()
|
||||
foreach ($blocker in $blockers) {
|
||||
if (-not (Test-ExactProperties $blocker `
|
||||
@('code', 'status', 'resolution', 'evidence'))) {
|
||||
throw 'Customer profile blocker resolution has an invalid fixed contract.'
|
||||
}
|
||||
$code = [string]$blocker.code
|
||||
if ($expectedCodes -cnotcontains $code -or
|
||||
$seenCodes -ccontains $code -or
|
||||
[string]$blocker.status -cne 'resolved' -or
|
||||
-not (Test-ExactProperties $blocker.resolution `
|
||||
@('evidenceArtifact', 'evidenceSha256', 'approvedBy', 'approvedAtUtc'))) {
|
||||
throw 'Customer profile blocker resolution set or status is invalid.'
|
||||
}
|
||||
$seenCodes += $code
|
||||
|
||||
$expectedArtifact = if ($code -ceq 'purchase_currency_field_not_configured') {
|
||||
'field_mapping'
|
||||
}
|
||||
else {
|
||||
'write_integration'
|
||||
}
|
||||
$artifact = [string]$blocker.resolution.evidenceArtifact
|
||||
$expectedHash = $null
|
||||
if ($artifact -ceq 'field_mapping') {
|
||||
$expectedHash = $FieldMappingHash
|
||||
}
|
||||
elseif ($artifact -ceq 'read_contract') {
|
||||
$expectedHash = $ReadContractHash
|
||||
}
|
||||
elseif ($artifact -ceq 'write_integration') {
|
||||
$expectedHash = $WriteIntegrationHash
|
||||
}
|
||||
$approvedAt = [DateTime]::MinValue
|
||||
$approvedAtValue = $blocker.resolution.approvedAtUtc
|
||||
if ($approvedAtValue -is [DateTime]) {
|
||||
# PowerShell 7 ConvertFrom-Json parses ISO timestamps eagerly. The
|
||||
# locked profile has already passed the CLI's raw exact-format gate.
|
||||
$approvedAt = [DateTime]$approvedAtValue
|
||||
$approvedAtValid = $approvedAt.Kind -eq [DateTimeKind]::Utc
|
||||
}
|
||||
else {
|
||||
$approvedAtValid = [DateTime]::TryParseExact(
|
||||
[string]$approvedAtValue,
|
||||
'o',
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
[Globalization.DateTimeStyles]::RoundtripKind,
|
||||
[ref]$approvedAt)
|
||||
}
|
||||
if ($artifact -cne $expectedArtifact) {
|
||||
throw 'Customer profile blocker resolution uses the wrong evidence artifact kind.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$expectedHash) -or
|
||||
[string]$blocker.resolution.evidenceSha256 -cne $expectedHash) {
|
||||
throw 'Customer profile blocker resolution is not bound to the exact signed evidence artifact.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([string]$blocker.resolution.approvedBy)) {
|
||||
throw 'Customer profile blocker resolution has no approving identity.'
|
||||
}
|
||||
if (-not $approvedAtValid -or
|
||||
$approvedAt.Kind -ne [DateTimeKind]::Utc) {
|
||||
throw 'Customer profile blocker resolution approval time is not exact UTC round-trip format.'
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
Verified = $true
|
||||
ResolvedBlockerCount = $seenCodes.Count
|
||||
}
|
||||
}
|
||||
|
||||
$inputDefinitions = @(
|
||||
@($VerifierCliPath, 64MB, 'Verifier CLI'),
|
||||
@($RuntimeConfigurationFile, 64KB, 'Runtime configuration'),
|
||||
@($CustomerProfileFile, 1MB, 'Customer profile'),
|
||||
@($FieldMappingEvidence, 1MB, 'Field mapping'),
|
||||
@($ReadContractEvidence, 4MB, 'Read contract evidence'),
|
||||
@($WriteIntegrationEvidence, 4MB, 'Write integration evidence')
|
||||
)
|
||||
$inputPaths = @($inputDefinitions | ForEach-Object {
|
||||
[System.IO.Path]::GetFullPath([string]$_[0])
|
||||
})
|
||||
$fullOutputCandidate = [System.IO.Path]::GetFullPath($OutputPath)
|
||||
if (@($inputPaths | Sort-Object -Unique).Count -ne $inputPaths.Count -or
|
||||
$inputPaths -contains $fullOutputCandidate) {
|
||||
throw 'Verifier, evidence inputs and output must all use distinct files.'
|
||||
}
|
||||
$inputLocks = New-Object 'System.Collections.Generic.List[System.IDisposable]'
|
||||
try {
|
||||
foreach ($definition in $inputDefinitions) {
|
||||
$inputLocks.Add((Open-InputLock `
|
||||
([string]$definition[0]) `
|
||||
([long]$definition[1]) `
|
||||
([string]$definition[2])))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
foreach ($lock in $inputLocks) { $lock.Dispose() }
|
||||
throw
|
||||
}
|
||||
|
||||
try {
|
||||
$issuedAt = [DateTime]::UtcNow
|
||||
$expiresAt = $issuedAt.AddDays($ValidDays)
|
||||
$thumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
$runtimeConfigurationHash = Get-FileSha256 `
|
||||
$RuntimeConfigurationFile 64KB 'Runtime configuration'
|
||||
$customerProfileHash = Get-FileSha256 `
|
||||
$CustomerProfileFile 1MB 'Customer profile'
|
||||
$profileVerification = Invoke-CustomerProfileVerifier `
|
||||
$VerifierCliPath $CustomerProfileFile $customerProfileHash $Workflow `
|
||||
$AccountBook $SubSystemId $ErpUser $ErpPassword
|
||||
if ((Get-FileSha256 $CustomerProfileFile 1MB 'Customer profile') -ne
|
||||
$customerProfileHash) {
|
||||
throw 'Customer profile changed while it was being verified.'
|
||||
}
|
||||
$fieldMappingHash = Get-FileSha256 `
|
||||
$FieldMappingEvidence 1MB 'Field mapping'
|
||||
$fieldMappingVerification = Invoke-FieldMappingVerifier `
|
||||
$VerifierCliPath $FieldMappingEvidence $Workflow $ModuleCode `
|
||||
$AccountBook $SubSystemId $ErpUser $ErpPassword
|
||||
if ((Get-FileSha256 $FieldMappingEvidence 1MB 'Field mapping') -ne
|
||||
$fieldMappingHash) {
|
||||
throw 'Field mapping changed while it was being verified.'
|
||||
}
|
||||
$readContractHash = Get-FileSha256 `
|
||||
$ReadContractEvidence 4MB 'Read contract evidence'
|
||||
$readContractVerification = Invoke-ReadContractEvidenceVerifier `
|
||||
$VerifierCliPath $ReadContractEvidence $Workflow $ModuleCode `
|
||||
$AccountBook $SubSystemId
|
||||
if ((Get-FileSha256 $ReadContractEvidence 4MB 'Read contract evidence') -ne
|
||||
$readContractHash) {
|
||||
throw 'Read contract evidence changed while it was being verified.'
|
||||
}
|
||||
$writeIntegrationHash = Get-FileSha256 `
|
||||
$WriteIntegrationEvidence 4MB 'Write integration evidence'
|
||||
$writeVerification = Invoke-WriteEvidenceVerifier `
|
||||
$VerifierCliPath $WriteIntegrationEvidence $runtimeConfigurationHash
|
||||
if ((Get-FileSha256 $WriteIntegrationEvidence 4MB 'Write integration evidence') -ne
|
||||
$writeIntegrationHash) {
|
||||
throw 'Write integration evidence changed while it was being verified.'
|
||||
}
|
||||
$profileResolutionVerification = Assert-ProfileResolutionBindings `
|
||||
$CustomerProfileFile $Workflow $ModuleCode $fieldMappingHash `
|
||||
$readContractHash $writeIntegrationHash
|
||||
$content = [ordered]@{
|
||||
packageType = 'workflow_write_acceptance_evidence'
|
||||
workflow = $Workflow
|
||||
moduleCode = $ModuleCode
|
||||
erpScope = [ordered]@{
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
}
|
||||
adapterId = $AdapterId
|
||||
adapterVersion = $AdapterVersion
|
||||
evidenceId = $EvidenceId
|
||||
runtimeConfigurationSha256 = $runtimeConfigurationHash
|
||||
customerProfileSha256 = $customerProfileHash
|
||||
fieldMappingSha256 = $fieldMappingHash
|
||||
readContractEvidenceSha256 = $readContractHash
|
||||
writeIntegrationEvidenceSha256 = $writeIntegrationHash
|
||||
requirements = [ordered]@{
|
||||
customerConfigurationValidated = $true
|
||||
parameterizedReadQueriesVerified = $true
|
||||
transactionalWriteVerified = $true
|
||||
persistentIdempotencyVerified = $true
|
||||
permissionRecheckVerified = $true
|
||||
windowsIntegrationVerified = $true
|
||||
criticalCatalogRuntimeRecheckVerified = $true
|
||||
}
|
||||
issuedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
validatedBy = $ValidatedBy
|
||||
note = '客户 Windows 验收完成;当前工作流画像阻断项已关闭,运行时配置、客户画像在线目录复核和三个输入证据文件的 SHA-256 已绑定。'
|
||||
}
|
||||
|
||||
$canonicalContent = $content | ConvertTo-Json -Compress -Depth 10
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
$contentBytes = $utf8.GetBytes($canonicalContent)
|
||||
$contentHash = Get-Sha256Hex $contentBytes
|
||||
$certificate = Find-SigningCertificate $thumbprint
|
||||
$rsa = $certificate.PrivateKey -as [System.Security.Cryptography.RSACryptoServiceProvider]
|
||||
if ($null -eq $rsa) {
|
||||
throw 'Signing certificate must expose an RSA CSP private key for the .NET Framework 4.0 client.'
|
||||
}
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$digest = $sha.ComputeHash($contentBytes)
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
$signature = $rsa.SignHash(
|
||||
$digest,
|
||||
[System.Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
|
||||
|
||||
$package = [ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
contentSha256 = $contentHash
|
||||
signatureAlgorithm = 'rsa-sha256'
|
||||
certificateThumbprint = $thumbprint
|
||||
signatureBase64 = [Convert]::ToBase64String($signature)
|
||||
content = $content
|
||||
}
|
||||
$body = $utf8.GetBytes(($package | ConvertTo-Json -Depth 10))
|
||||
$fullOutput = [System.IO.Path]::GetFullPath($OutputPath)
|
||||
$directory = [System.IO.Path]::GetDirectoryName($fullOutput)
|
||||
if ([string]::IsNullOrWhiteSpace($directory) -or -not [System.IO.Directory]::Exists($directory)) {
|
||||
throw "Output directory does not exist: $directory"
|
||||
}
|
||||
$stream = [System.IO.File]::Open(
|
||||
$fullOutput,
|
||||
[System.IO.FileMode]::CreateNew,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::None)
|
||||
try {
|
||||
$stream.Write($body, 0, $body.Length)
|
||||
$stream.Flush()
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
outputFile = $fullOutput
|
||||
workflow = $Workflow
|
||||
moduleCode = $ModuleCode
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
evidenceId = $EvidenceId
|
||||
evidenceSha256 = $contentHash
|
||||
runtimeConfigurationSha256 = $content.runtimeConfigurationSha256
|
||||
customerProfileSha256 = $content.customerProfileSha256
|
||||
customerProfileOnlineMetadataMatches = $profileVerification.onlineMetadataMatches
|
||||
customerProfileCriticalCatalogMatches = `
|
||||
$profileVerification.criticalCatalogContractMatches
|
||||
customerProfileResolutionBindingVerified = `
|
||||
$profileResolutionVerification.Verified
|
||||
customerProfileResolvedBlockerCount = `
|
||||
$profileResolutionVerification.ResolvedBlockerCount
|
||||
fieldMapReady = $fieldMappingVerification.fieldMapReady
|
||||
readContractVerified = $readContractVerification.verified
|
||||
validatedAtUtc = $issuedAt.ToString('o')
|
||||
expiresAtUtc = $expiresAt.ToString('o')
|
||||
certificateThumbprint = $thumbprint
|
||||
sourceCommit = $writeVerification.sourceCommit
|
||||
packageSha256 = $writeVerification.packageSha256
|
||||
writeIntegrationCaseCount = $writeVerification.caseCount
|
||||
nextStep = 'Use these exact values when inserting the V2 readiness row, then run lserp-cli adapters verify-acceptance-evidence.'
|
||||
} | ConvertTo-Json -Depth 5
|
||||
}
|
||||
finally {
|
||||
foreach ($lock in $inputLocks) { $lock.Dispose() }
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('purchase', 'leave', 'both')]
|
||||
[string]$Workflow,
|
||||
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
|
||||
[string]$PurchaseModuleCode = '',
|
||||
[ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')]
|
||||
[string]$LeaveModuleCode = '',
|
||||
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
|
||||
[string]$PurchaseAdapterId = '',
|
||||
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
|
||||
[string]$LeaveAdapterId = '',
|
||||
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
|
||||
[string]$PurchaseAdapterVersion = '',
|
||||
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
|
||||
[string]$LeaveAdapterVersion = '',
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$')]
|
||||
[string]$AuthorizationId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$')]
|
||||
[string]$CustomerId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')]
|
||||
[string]$EnvironmentId,
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$AccountBook,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')]
|
||||
[string]$SubSystemId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')]
|
||||
[string]$ErpUserId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 128)]
|
||||
[string]$ErpUserName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$DatabaseScopeFingerprint,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile,
|
||||
[Parameter(Mandatory = $true)][string]$CustomerProfileFile,
|
||||
[Parameter(Mandatory = $true)][string]$RolloutPolicyFile,
|
||||
[Parameter(Mandatory = $true)][string]$CommercialPackageFile,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$SourceCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedPackageSha256,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$ErpExecutablePath,
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeCliPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
|
||||
[string]$ExpectedRuntimeCliVersion,
|
||||
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedErpSignerThumbprint,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedCliSignerThumbprint,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedRuntimeCliSignerThumbprint,
|
||||
|
||||
[Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$ApprovedBy,
|
||||
[Parameter(Mandatory = $true)][switch]$DatabaseBackupVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$RestoreProcedureVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$NonProductionEnvironmentVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$NativeConfirmationVerified,
|
||||
[Parameter(Mandatory = $true)][switch]$TransactionAuditVerified,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')]
|
||||
[string]$CertificateThumbprint,
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath,
|
||||
[Parameter(Mandatory = $true)][string]$TokenVaultPath,
|
||||
[ValidateRange(1, 24)][int]$ValidHours = 8
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
throw 'workflow_uat_authorization_failed:windows_powershell_51_required'
|
||||
}
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'workflow_uat_authorization_failed:elevated_operator_required'
|
||||
}
|
||||
|
||||
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$locks = New-Object System.Collections.Generic.List[IO.FileStream]
|
||||
$published = New-Object System.Collections.Generic.List[string]
|
||||
$token = $null
|
||||
$tokenHash = $null
|
||||
$plainTokenBytes = $null
|
||||
|
||||
function Throw-UatError([string]$Code) {
|
||||
throw ('workflow_uat_authorization_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-UatError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Open-LockedInput(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$ExpectedFileName,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) { Throw-UatError $Code }
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
||||
(-not [string]::IsNullOrWhiteSpace($ExpectedFileName) -and
|
||||
[IO.Path]::GetFileName($full) -cne $ExpectedFileName)) {
|
||||
Throw-UatError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
|
||||
$stream = [IO.File]::Open(
|
||||
$full,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read)
|
||||
if ($stream.Length -le 0 -or $stream.Length -gt $MaximumBytes) {
|
||||
$stream.Dispose()
|
||||
Throw-UatError $Code
|
||||
}
|
||||
$script:locks.Add($stream)
|
||||
return [pscustomobject]@{ Path = $full; Stream = $stream }
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NewPath([string]$Path, [string]$Extension, [string]$Code) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetExtension($full) -ine $Extension -or
|
||||
[IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) {
|
||||
Throw-UatError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-LockedSha256([IO.FileStream]$Stream) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Stream.Position = 0
|
||||
$hash = ([BitConverter]::ToString($sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
||||
$Stream.Position = 0
|
||||
return $hash
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-LockedUtf8Text([IO.FileStream]$Stream, [string]$Code) {
|
||||
$bytes = $null
|
||||
try {
|
||||
if ($Stream.Length -gt [int]::MaxValue) { Throw-UatError $Code }
|
||||
$bytes = New-Object byte[] ([int]$Stream.Length)
|
||||
$Stream.Position = 0
|
||||
$offset = 0
|
||||
while ($offset -lt $bytes.Length) {
|
||||
$read = $Stream.Read($bytes, $offset, $bytes.Length - $offset)
|
||||
if ($read -le 0) { Throw-UatError $Code }
|
||||
$offset += $read
|
||||
}
|
||||
$Stream.Position = 0
|
||||
return $utf8.GetString($bytes)
|
||||
}
|
||||
catch {
|
||||
$Stream.Position = 0
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError $Code
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $bytes) { [Array]::Clear($bytes, 0, $bytes.Length) }
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Authenticode(
|
||||
[string]$Path,
|
||||
[string]$ExpectedThumbprint,
|
||||
[string]$Code
|
||||
) {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
||||
$actual = if ($null -eq $signature.SignerCertificate) {
|
||||
''
|
||||
} else {
|
||||
([string]$signature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant()
|
||||
}
|
||||
if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or
|
||||
$actual -cne $ExpectedThumbprint.ToUpperInvariant()) {
|
||||
Throw-UatError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Find-SigningCertificate([string]$Thumbprint) {
|
||||
$normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
foreach ($location in @('CurrentUser', 'LocalMachine')) {
|
||||
$path = "Cert:\$location\TrustedPeople\$normalized"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$certificate = Get-Item -LiteralPath $path
|
||||
if (-not $certificate.HasPrivateKey -or
|
||||
(Get-Date) -lt $certificate.NotBefore -or
|
||||
(Get-Date) -gt $certificate.NotAfter) {
|
||||
Throw-UatError 'signing_certificate_invalid'
|
||||
}
|
||||
$rsa = $certificate.PrivateKey -as [Security.Cryptography.RSACryptoServiceProvider]
|
||||
if ($null -eq $rsa) { Throw-UatError 'signing_certificate_not_rsa_csp' }
|
||||
return [pscustomobject]@{ Certificate = $certificate; Rsa = $rsa }
|
||||
}
|
||||
}
|
||||
Throw-UatError 'signing_certificate_not_found'
|
||||
}
|
||||
|
||||
function New-CaseToken {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try {
|
||||
$rng.GetBytes($bytes)
|
||||
return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
}
|
||||
finally {
|
||||
$rng.Dispose()
|
||||
[Array]::Clear($bytes, 0, $bytes.Length)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-NewUtf8File([string]$Path, [string]$Text) {
|
||||
$bytes = $utf8.GetBytes($Text)
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None,
|
||||
4096,
|
||||
[IO.FileOptions]::WriteThrough)
|
||||
try {
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
$script:published.Add($Path)
|
||||
}
|
||||
|
||||
function Write-RestrictedVault([string]$Path, [string]$Text) {
|
||||
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$systemSid = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
||||
$null)
|
||||
$security = New-Object Security.AccessControl.FileSecurity
|
||||
$security.SetOwner($currentSid)
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
foreach ($sid in @($currentSid, $systemSid)) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sid,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[Security.AccessControl.AccessControlType]::Allow)
|
||||
$security.AddAccessRule($rule)
|
||||
}
|
||||
$bytes = $utf8.GetBytes($Text)
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[Security.AccessControl.FileSystemRights]::ReadData -bor
|
||||
[Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[Security.AccessControl.FileSystemRights]::ReadAttributes -bor
|
||||
[Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[Security.AccessControl.FileSystemRights]::ReadPermissions,
|
||||
[IO.FileShare]::None,
|
||||
4096,
|
||||
[IO.FileOptions]::WriteThrough,
|
||||
$security)
|
||||
try {
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
$script:published.Add($Path)
|
||||
& "$env:SystemRoot\System32\icacls.exe" $Path '/setintegritylevel' 'H' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Throw-UatError 'token_vault_integrity_label_failed' }
|
||||
$sections = [Security.AccessControl.AccessControlSections]::All
|
||||
$acl = [IO.File]::GetAccessControl($Path, $sections)
|
||||
$ownerSid = $acl.GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = @($acl.GetAccessRules(
|
||||
$true,
|
||||
$true,
|
||||
[Security.Principal.SecurityIdentifier]))
|
||||
$seen = @{}
|
||||
foreach ($rule in $rules) {
|
||||
$sidValue = [string]$rule.IdentityReference.Value
|
||||
if ($rule.IsInherited -or
|
||||
$rule.AccessControlType -ne
|
||||
[Security.AccessControl.AccessControlType]::Allow -or
|
||||
($sidValue -cne $currentSid.Value -and
|
||||
$sidValue -cne $systemSid.Value) -or
|
||||
(($rule.FileSystemRights -band
|
||||
[Security.AccessControl.FileSystemRights]::FullControl) -ne
|
||||
[Security.AccessControl.FileSystemRights]::FullControl) -or
|
||||
$seen.ContainsKey($sidValue)) {
|
||||
Throw-UatError 'token_vault_acl_invalid'
|
||||
}
|
||||
$seen[$sidValue] = $true
|
||||
}
|
||||
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
|
||||
if (-not $acl.AreAccessRulesProtected -or
|
||||
$ownerSid -cne $currentSid.Value -or
|
||||
$rules.Count -ne 2 -or
|
||||
-not $seen.ContainsKey($currentSid.Value) -or
|
||||
-not $seen.ContainsKey($systemSid.Value) -or
|
||||
$sddl -cnotmatch 'S:.*\(ML;;NW;;;HI\)') {
|
||||
Throw-UatError 'token_vault_acl_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($names.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($names -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-PackagedRuntimeCliContract([string]$PackagePath) {
|
||||
try {
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [IO.Compression.ZipFile]::OpenRead($PackagePath)
|
||||
try {
|
||||
$manifestEntries = @($archive.Entries | Where-Object {
|
||||
[string]$_.FullName -cmatch '(?:^|/)SHA256SUMS\.json$'
|
||||
})
|
||||
$runtimeEntries = @($archive.Entries | Where-Object {
|
||||
[string]$_.FullName -cmatch
|
||||
'(?:^|/)Host/lserp-agent-cli\.exe$'
|
||||
})
|
||||
if ($manifestEntries.Count -ne 1 -or
|
||||
$runtimeEntries.Count -ne 1 -or
|
||||
$manifestEntries[0].Length -le 0 -or
|
||||
$manifestEntries[0].Length -gt 4MB -or
|
||||
$runtimeEntries[0].Length -le 0 -or
|
||||
$runtimeEntries[0].Length -gt 128MB) {
|
||||
Throw-UatError 'package_runtime_cli_missing'
|
||||
}
|
||||
$manifestStream = $manifestEntries[0].Open()
|
||||
$manifestReader = $null
|
||||
try {
|
||||
$manifestReader = [IO.StreamReader]::new(
|
||||
$manifestStream, $utf8, $false, 4096, $false)
|
||||
$manifestText = $manifestReader.ReadToEnd()
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $manifestReader) { $manifestReader.Dispose() }
|
||||
else { $manifestStream.Dispose() }
|
||||
}
|
||||
try { $manifest = $manifestText | ConvertFrom-Json }
|
||||
catch { Throw-UatError 'package_manifest_invalid' }
|
||||
if (-not (Test-ExactProperties $manifest @(
|
||||
'schemaVersion', 'packageVersion', 'generatedAtUtc',
|
||||
'files')) -or
|
||||
[string]$manifest.schemaVersion -cne '1.0' -or
|
||||
[string]$manifest.packageVersion -cne
|
||||
$ExpectedRuntimeCliVersion -or
|
||||
$manifest.files -isnot [array]) {
|
||||
Throw-UatError 'package_manifest_invalid'
|
||||
}
|
||||
$manifestRuntime = @($manifest.files | Where-Object {
|
||||
[string]$_.path -ceq 'Host/lserp-agent-cli.exe'
|
||||
})
|
||||
if ($manifestRuntime.Count -ne 1 -or
|
||||
-not (Test-ExactProperties $manifestRuntime[0] @(
|
||||
'path', 'sizeBytes', 'sha256')) -or
|
||||
[long]$manifestRuntime[0].sizeBytes -ne
|
||||
[long]$runtimeEntries[0].Length -or
|
||||
[string]$manifestRuntime[0].sha256 -cnotmatch
|
||||
'^[a-f0-9]{64}$') {
|
||||
Throw-UatError 'package_runtime_cli_manifest_invalid'
|
||||
}
|
||||
$runtimeStream = $runtimeEntries[0].Open()
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$archiveRuntimeHash = ([BitConverter]::ToString(
|
||||
$sha.ComputeHash($runtimeStream))).Replace(
|
||||
'-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
$runtimeStream.Dispose()
|
||||
}
|
||||
if ($archiveRuntimeHash -cne
|
||||
[string]$manifestRuntime[0].sha256) {
|
||||
Throw-UatError 'package_runtime_cli_hash_mismatch'
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
Version = [string]$manifest.packageVersion
|
||||
SizeBytes = [long]$runtimeEntries[0].Length
|
||||
Sha256 = $archiveRuntimeHash
|
||||
}
|
||||
}
|
||||
finally { $archive.Dispose() }
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError 'package_runtime_cli_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
$purchaseCases = @(
|
||||
'purchase_unique_match_commit', 'purchase_ambiguous_match_blocked',
|
||||
'purchase_overallocation_blocked', 'purchase_permission_denied',
|
||||
'purchase_database_permission_recheck_denied',
|
||||
'purchase_currency_field_missing_blocked',
|
||||
'purchase_currency_crosswalk_unapproved_blocked',
|
||||
'purchase_row_scope_denied', 'purchase_runtime_recheck_blocked',
|
||||
'purchase_transaction_rollback', 'purchase_idempotency_replay',
|
||||
'purchase_idempotency_conflict', 'purchase_audit_correlated'
|
||||
)
|
||||
$leaveCases = @(
|
||||
'leave_natural_language_resolution', 'leave_multi_day_calendar_resolution',
|
||||
'leave_resolution_proof_bypass_blocked', 'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked', 'leave_time_segment_required_blocked',
|
||||
'leave_local_time_zone_rejected', 'leave_other_employee_denied',
|
||||
'leave_permission_denied', 'leave_database_permission_recheck_denied',
|
||||
'leave_create_draft_commit', 'leave_submit_separate_confirmation',
|
||||
'leave_overlap_blocked', 'leave_stale_flow_type_blocked',
|
||||
'leave_runtime_recheck_blocked', 'leave_transaction_rollback',
|
||||
'leave_idempotency_replay', 'leave_idempotency_conflict',
|
||||
'leave_audit_correlated'
|
||||
)
|
||||
|
||||
function Get-ExpectedCommand([string]$CaseCode) {
|
||||
if ($CaseCode.StartsWith('purchase_', [StringComparison]::Ordinal)) {
|
||||
return 'purchase.invoice.create'
|
||||
}
|
||||
if ($CaseCode -in @(
|
||||
'leave_natural_language_resolution',
|
||||
'leave_multi_day_calendar_resolution',
|
||||
'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked',
|
||||
'leave_time_segment_required_blocked',
|
||||
'leave_other_employee_denied')) {
|
||||
return 'hr.leave.resolve'
|
||||
}
|
||||
if ($CaseCode -eq 'leave_submit_separate_confirmation') {
|
||||
return 'hr.leave.submit'
|
||||
}
|
||||
return 'hr.leave.create'
|
||||
}
|
||||
|
||||
function Get-AllowedCommands([string]$ExpectedCommand) {
|
||||
if ($ExpectedCommand -eq 'purchase.invoice.create') {
|
||||
return @('purchase.invoice.resolve', 'purchase.invoice.create')
|
||||
}
|
||||
if ($ExpectedCommand -eq 'hr.leave.create') {
|
||||
return @('hr.leave.resolve', 'hr.leave.create')
|
||||
}
|
||||
return @($ExpectedCommand)
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not $DatabaseBackupVerified.IsPresent -or
|
||||
-not $RestoreProcedureVerified.IsPresent -or
|
||||
-not $NonProductionEnvironmentVerified.IsPresent -or
|
||||
-not $NativeConfirmationVerified.IsPresent -or
|
||||
-not $TransactionAuditVerified.IsPresent) {
|
||||
Throw-UatError 'explicit_safety_attestation_required'
|
||||
}
|
||||
if (($Workflow -in @('purchase', 'both')) -and
|
||||
([string]::IsNullOrWhiteSpace($PurchaseModuleCode) -or
|
||||
[string]::IsNullOrWhiteSpace($PurchaseAdapterId) -or
|
||||
[string]::IsNullOrWhiteSpace($PurchaseAdapterVersion))) {
|
||||
Throw-UatError 'purchase_contract_required'
|
||||
}
|
||||
if (($Workflow -in @('leave', 'both')) -and
|
||||
([string]::IsNullOrWhiteSpace($LeaveModuleCode) -or
|
||||
[string]::IsNullOrWhiteSpace($LeaveAdapterId) -or
|
||||
[string]::IsNullOrWhiteSpace($LeaveAdapterVersion))) {
|
||||
Throw-UatError 'leave_contract_required'
|
||||
}
|
||||
if ($AccountBook -cne $AccountBook.Trim() -or
|
||||
$ErpUserName -cne $ErpUserName.Trim() -or
|
||||
$ApprovedBy -cne $ApprovedBy.Trim() -or
|
||||
$ErpUserName -match '[\x00-\x1F\x7F]') {
|
||||
Throw-UatError 'scope_text_invalid'
|
||||
}
|
||||
|
||||
$runtime = Open-LockedInput $RuntimeConfigurationFile 1MB '' 'runtime_configuration_invalid'
|
||||
$profile = Open-LockedInput $CustomerProfileFile 4MB '' 'customer_profile_invalid'
|
||||
$rollout = Open-LockedInput $RolloutPolicyFile 256KB '' 'rollout_policy_invalid'
|
||||
$package = Open-LockedInput $CommercialPackageFile 4GB '' 'commercial_package_invalid'
|
||||
$erp = Open-LockedInput $ErpExecutablePath 256MB 'Ls_ERP.exe' 'erp_executable_invalid'
|
||||
$runtimeCli = Open-LockedInput `
|
||||
$RuntimeCliPath 128MB 'lserp-agent-cli.exe' 'runtime_cli_invalid'
|
||||
$cli = Open-LockedInput $VerifierCliPath 128MB 'lserp-cli.exe' 'verifier_cli_invalid'
|
||||
$output = Resolve-NewPath $OutputPath '.json' 'authorization_output_invalid'
|
||||
$vaultOutput = Resolve-NewPath $TokenVaultPath '.json' 'token_vault_output_invalid'
|
||||
if ($output -ieq $vaultOutput) { Throw-UatError 'output_path_conflict' }
|
||||
|
||||
try {
|
||||
$rolloutDocument = (Get-LockedUtf8Text `
|
||||
$rollout.Stream 'rollout_policy_invalid') | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw }
|
||||
Throw-UatError 'rollout_policy_invalid'
|
||||
}
|
||||
if (-not (Test-ExactProperties $rolloutDocument @(
|
||||
'schemaVersion', 'customerId', 'databaseScopeFingerprint',
|
||||
'defaultAction', 'rules')) -or
|
||||
[string]$rolloutDocument.schemaVersion -cne '1.1' -or
|
||||
[string]$rolloutDocument.customerId -cne $CustomerId -or
|
||||
[string]$rolloutDocument.databaseScopeFingerprint -cne
|
||||
$DatabaseScopeFingerprint.ToLowerInvariant() -or
|
||||
[string]$rolloutDocument.defaultAction -cne 'deny' -or
|
||||
$null -eq $rolloutDocument.rules) {
|
||||
Throw-UatError 'rollout_policy_scope_mismatch'
|
||||
}
|
||||
|
||||
$packageHash = Get-LockedSha256 $package.Stream
|
||||
if ($packageHash -cne $ExpectedPackageSha256.ToLowerInvariant()) {
|
||||
Throw-UatError 'commercial_package_hash_mismatch'
|
||||
}
|
||||
$packageRuntimeCli = Get-PackagedRuntimeCliContract $package.Path
|
||||
$runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream
|
||||
if ($packageRuntimeCli.Version -cne $ExpectedRuntimeCliVersion -or
|
||||
$packageRuntimeCli.SizeBytes -ne $runtimeCli.Stream.Length -or
|
||||
$packageRuntimeCli.Sha256 -cne $runtimeCliHash) {
|
||||
Throw-UatError 'runtime_cli_package_binding_mismatch'
|
||||
}
|
||||
Assert-Authenticode $erp.Path $ExpectedErpSignerThumbprint 'erp_authenticode_invalid'
|
||||
Assert-Authenticode $runtimeCli.Path `
|
||||
$ExpectedRuntimeCliSignerThumbprint 'runtime_cli_authenticode_invalid'
|
||||
Assert-Authenticode $cli.Path $ExpectedCliSignerThumbprint 'cli_authenticode_invalid'
|
||||
$runtimeIdentityCorrelation = 'uat-runtime-version-' +
|
||||
[Guid]::NewGuid().ToString('N')
|
||||
$runtimeIdentityOutput = @(& $runtimeCli.Path version `
|
||||
--correlation-id $runtimeIdentityCorrelation 2>&1)
|
||||
$runtimeIdentityExit = $LASTEXITCODE
|
||||
$runtimeIdentityText = (($runtimeIdentityOutput | ForEach-Object {
|
||||
[string]$_
|
||||
}) -join [Environment]::NewLine)
|
||||
try { $runtimeIdentityEnvelope = $runtimeIdentityText | ConvertFrom-Json }
|
||||
catch { Throw-UatError 'runtime_cli_identity_invalid' }
|
||||
$runtimeIdentity = $runtimeIdentityEnvelope.data
|
||||
if ($runtimeIdentityExit -ne 0 -or
|
||||
-not (Test-ExactProperties $runtimeIdentityEnvelope @(
|
||||
'ok', 'correlationId', 'data')) -or
|
||||
$runtimeIdentityEnvelope.ok -ne $true -or
|
||||
[string]$runtimeIdentityEnvelope.correlationId -cne
|
||||
$runtimeIdentityCorrelation -or
|
||||
-not (Test-ExactProperties $runtimeIdentity @(
|
||||
'component', 'version', 'protocolVersion', 'bridgeOnly',
|
||||
'databaseDirectAccess', 'sessionSource')) -or
|
||||
[string]$runtimeIdentity.component -cne 'lserp-agent-cli' -or
|
||||
[string]$runtimeIdentity.version -cne $ExpectedRuntimeCliVersion -or
|
||||
[string]$runtimeIdentity.protocolVersion -cne '1.0' -or
|
||||
$runtimeIdentity.bridgeOnly -ne $true -or
|
||||
$runtimeIdentity.databaseDirectAccess -ne $false -or
|
||||
[string]$runtimeIdentity.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
(Get-LockedSha256 $runtimeCli.Stream) -cne $runtimeCliHash) {
|
||||
Throw-UatError 'runtime_cli_identity_invalid'
|
||||
}
|
||||
|
||||
$issuedAt = [DateTime]::UtcNow
|
||||
$expiresAt = $issuedAt.AddHours($ValidHours)
|
||||
$entropy = $utf8.GetBytes($AuthorizationId)
|
||||
$vaultEntries = New-Object System.Collections.Generic.List[object]
|
||||
$workflowObjects = New-Object System.Collections.Generic.List[object]
|
||||
$workflowNames = if ($Workflow -eq 'both') {
|
||||
@('purchase', 'leave')
|
||||
} else { @($Workflow) }
|
||||
foreach ($workflowName in $workflowNames) {
|
||||
$caseCodes = if ($workflowName -eq 'purchase') {
|
||||
$purchaseCases
|
||||
} else { $leaveCases }
|
||||
$caseObjects = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($caseCode in $caseCodes) {
|
||||
$token = New-CaseToken
|
||||
$expectedCommand = Get-ExpectedCommand $caseCode
|
||||
$plainTokenBytes = $utf8.GetBytes($token)
|
||||
try {
|
||||
$tokenHash = Get-Sha256Hex $plainTokenBytes
|
||||
$protected = [Security.Cryptography.ProtectedData]::Protect(
|
||||
$plainTokenBytes,
|
||||
$entropy,
|
||||
[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
||||
}
|
||||
finally {
|
||||
[Array]::Clear(
|
||||
$plainTokenBytes,
|
||||
0,
|
||||
$plainTokenBytes.Length)
|
||||
}
|
||||
$caseObjects.Add([pscustomobject][ordered]@{
|
||||
caseCode = $caseCode
|
||||
expectedCommandName = $expectedCommand
|
||||
allowedCommands = @(Get-AllowedCommands $expectedCommand)
|
||||
tokenSha256 = $tokenHash
|
||||
})
|
||||
$vaultEntries.Add([pscustomobject][ordered]@{
|
||||
workflow = $workflowName
|
||||
caseCode = $caseCode
|
||||
protectedTokenBase64 = [Convert]::ToBase64String($protected)
|
||||
})
|
||||
$token = $null
|
||||
$tokenHash = $null
|
||||
$plainTokenBytes = $null
|
||||
}
|
||||
$workflowObjects.Add([pscustomobject][ordered]@{
|
||||
workflow = $workflowName
|
||||
moduleCode = if ($workflowName -eq 'purchase') {
|
||||
$PurchaseModuleCode
|
||||
} else { $LeaveModuleCode }
|
||||
adapterId = if ($workflowName -eq 'purchase') {
|
||||
$PurchaseAdapterId
|
||||
} else { $LeaveAdapterId }
|
||||
adapterVersion = if ($workflowName -eq 'purchase') {
|
||||
$PurchaseAdapterVersion
|
||||
} else { $LeaveAdapterVersion }
|
||||
cases = @($caseObjects)
|
||||
})
|
||||
}
|
||||
|
||||
$content = [pscustomobject][ordered]@{
|
||||
packageType = 'workflow_write_uat_authorization'
|
||||
authorizationId = $AuthorizationId
|
||||
customerId = $CustomerId
|
||||
environmentId = $EnvironmentId
|
||||
environmentClass = 'recoverable_uat'
|
||||
erpScope = [pscustomobject][ordered]@{
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
userId = $ErpUserId
|
||||
userName = $ErpUserName
|
||||
databaseScopeFingerprint =
|
||||
$DatabaseScopeFingerprint.ToLowerInvariant()
|
||||
}
|
||||
runtimeConfigurationSha256 = Get-LockedSha256 $runtime.Stream
|
||||
customerProfileSha256 = Get-LockedSha256 $profile.Stream
|
||||
rolloutPolicySha256 = Get-LockedSha256 $rollout.Stream
|
||||
sourceCommit = $SourceCommit.ToLowerInvariant()
|
||||
packageSha256 = $packageHash
|
||||
erpExecutable = [pscustomobject][ordered]@{
|
||||
fileName = 'Ls_ERP.exe'
|
||||
sha256 = Get-LockedSha256 $erp.Stream
|
||||
signerThumbprint = $ExpectedErpSignerThumbprint.ToUpperInvariant()
|
||||
requiresElevation = $false
|
||||
}
|
||||
runtimeCli = [pscustomobject][ordered]@{
|
||||
fileName = 'lserp-agent-cli.exe'
|
||||
version = $ExpectedRuntimeCliVersion
|
||||
sha256 = $runtimeCliHash
|
||||
signerThumbprint =
|
||||
$ExpectedRuntimeCliSignerThumbprint.ToUpperInvariant()
|
||||
requiresElevation = $false
|
||||
bridgeOnly = $true
|
||||
databaseDirectAccess = $false
|
||||
sessionSource = 'current_logged_in_erp_process'
|
||||
}
|
||||
verifierCli = [pscustomobject][ordered]@{
|
||||
fileName = 'lserp-cli.exe'
|
||||
sha256 = Get-LockedSha256 $cli.Stream
|
||||
signerThumbprint = $ExpectedCliSignerThumbprint.ToUpperInvariant()
|
||||
requiresElevation = $true
|
||||
}
|
||||
safety = [pscustomobject][ordered]@{
|
||||
databaseBackupVerified = $true
|
||||
restoreProcedureVerified = $true
|
||||
nonProductionEnvironmentVerified = $true
|
||||
productionUseProhibited = $true
|
||||
nativeConfirmationRequired = $true
|
||||
transactionAndAuditRequired = $true
|
||||
maximumPlanAttemptsPerCase = 6
|
||||
maximumExecuteAttemptsPerCase = 3
|
||||
}
|
||||
workflows = @($workflowObjects)
|
||||
issuedAtUtc = $issuedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
expiresAtUtc = $expiresAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
approvedBy = $ApprovedBy
|
||||
note = '仅授权在已验证备份和恢复流程的客户 UAT 库收集固定写集成用例;严禁生产使用。'
|
||||
}
|
||||
$contentJson = $content | ConvertTo-Json -Depth 20 -Compress
|
||||
$contentBytes = $utf8.GetBytes($contentJson)
|
||||
$contentHash = Get-Sha256Hex $contentBytes
|
||||
$signer = Find-SigningCertificate $CertificateThumbprint
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $digest = $sha.ComputeHash($contentBytes) } finally { $sha.Dispose() }
|
||||
$signatureBytes = $signer.Rsa.SignHash(
|
||||
$digest,
|
||||
[Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256'))
|
||||
$root = [pscustomobject][ordered]@{
|
||||
schemaVersion = '1.2'
|
||||
contentSha256 = $contentHash
|
||||
signatureAlgorithm = 'rsa-sha256'
|
||||
certificateThumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant()
|
||||
signatureBase64 = [Convert]::ToBase64String($signatureBytes)
|
||||
content = $content
|
||||
}
|
||||
|
||||
$vault = [pscustomobject][ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
authorizationId = $AuthorizationId
|
||||
protectedForUserSid = $identity.User.Value
|
||||
protectionScope = 'dpapi_current_user_high_integrity'
|
||||
createdAtUtc = $issuedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
entries = @($vaultEntries)
|
||||
}
|
||||
Write-RestrictedVault $vaultOutput ($vault | ConvertTo-Json -Depth 8 -Compress)
|
||||
Write-NewUtf8File $output ($root | ConvertTo-Json -Depth 24 -Compress)
|
||||
|
||||
foreach ($input in @(
|
||||
$runtime, $profile, $rollout, $package, $erp, $runtimeCli, $cli)) {
|
||||
if ((Get-LockedSha256 $input.Stream) -cne
|
||||
$(if ($input -eq $runtime) { $content.runtimeConfigurationSha256 }
|
||||
elseif ($input -eq $profile) { $content.customerProfileSha256 }
|
||||
elseif ($input -eq $rollout) { $content.rolloutPolicySha256 }
|
||||
elseif ($input -eq $package) { $content.packageSha256 }
|
||||
elseif ($input -eq $erp) { $content.erpExecutable.sha256 }
|
||||
elseif ($input -eq $runtimeCli) { $content.runtimeCli.sha256 }
|
||||
else { $content.verifierCli.sha256 })) {
|
||||
Throw-UatError 'locked_input_changed'
|
||||
}
|
||||
}
|
||||
|
||||
$verifyOutput = @(& $cli.Path 'acceptance' 'verify-uat-authorization' `
|
||||
'--input' $output '--json' 2>&1)
|
||||
$verifyExit = $LASTEXITCODE
|
||||
if ($verifyExit -ne 0) { Throw-UatError 'self_verification_failed' }
|
||||
try { $verified = (($verifyOutput | ForEach-Object { [string]$_ }) -join "`n") | ConvertFrom-Json }
|
||||
catch { Throw-UatError 'self_verification_invalid_json' }
|
||||
if (-not (Test-ExactProperties $verified @('ok', 'correlationId', 'data')) -or
|
||||
$verified.ok -ne $true -or
|
||||
$verified.data.packageType -cne 'workflow_write_uat_authorization' -or
|
||||
$verified.data.schemaVersion -cne '1.2' -or
|
||||
$verified.data.authorizationId -cne $AuthorizationId -or
|
||||
$verified.data.sourceSha256 -cne (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() -or
|
||||
$verified.data.runtimeConfigurationSha256 -cne $content.runtimeConfigurationSha256 -or
|
||||
$verified.data.customerProfileSha256 -cne $content.customerProfileSha256 -or
|
||||
$verified.data.rolloutPolicySha256 -cne $content.rolloutPolicySha256 -or
|
||||
$verified.data.packageSha256 -cne $packageHash -or
|
||||
[string]$verified.data.runtimeCli.fileName -cne
|
||||
'lserp-agent-cli.exe' -or
|
||||
[string]$verified.data.runtimeCli.version -cne
|
||||
$ExpectedRuntimeCliVersion -or
|
||||
[string]$verified.data.runtimeCli.sha256 -cne $runtimeCliHash -or
|
||||
[string]$verified.data.runtimeCli.signerThumbprint -cne
|
||||
$ExpectedRuntimeCliSignerThumbprint.ToUpperInvariant() -or
|
||||
$verified.data.runtimeCli.requiresElevation -ne $false -or
|
||||
$verified.data.runtimeCli.bridgeOnly -ne $true -or
|
||||
$verified.data.runtimeCli.databaseDirectAccess -ne $false -or
|
||||
[string]$verified.data.runtimeCli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
$verified.data.erpScope.userIdSha256 -cne
|
||||
(Get-Sha256Hex ($utf8.GetBytes($ErpUserId))) -or
|
||||
$verified.data.erpScope.userNameSha256 -cne
|
||||
(Get-Sha256Hex ($utf8.GetBytes($ErpUserName))) -or
|
||||
$verified.data.erpScope.databaseScopeFingerprint -cne
|
||||
$DatabaseScopeFingerprint.ToLowerInvariant() -or
|
||||
$verified.data.signatureVerified -ne $true -or
|
||||
$verified.data.uatAuthorized -ne $true -or
|
||||
$verified.data.productionReady -ne $false) {
|
||||
Throw-UatError 'self_verification_contract_mismatch'
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
authorizationPath = $output
|
||||
authorizationSourceSha256 = [string]$verified.data.sourceSha256
|
||||
authorizationContentSha256 = $contentHash
|
||||
authorizationId = $AuthorizationId
|
||||
tokenVaultPath = $vaultOutput
|
||||
tokenVaultContainsPlaintext = $false
|
||||
expiresAtUtc = $content.expiresAtUtc
|
||||
workflowCount = @($workflowObjects).Count
|
||||
caseCount = @($vaultEntries).Count
|
||||
productionReady = $false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
foreach ($path in @($published)) {
|
||||
if ([IO.File]::Exists($path)) {
|
||||
try { [IO.File]::Delete($path) } catch { }
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
$token = $null
|
||||
$tokenHash = $null
|
||||
if ($null -ne $plainTokenBytes) {
|
||||
[Array]::Clear($plainTokenBytes, 0, $plainTokenBytes.Length)
|
||||
$plainTokenBytes = $null
|
||||
}
|
||||
foreach ($stream in @($locks)) {
|
||||
if ($null -ne $stream) { $stream.Dispose() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ValidateSet('purchase', 'leave')][string]$Workflow,
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$codes = if ($Workflow -eq 'purchase') {
|
||||
@(
|
||||
'purchase_unique_match_commit', 'purchase_ambiguous_match_blocked',
|
||||
'purchase_overallocation_blocked', 'purchase_permission_denied',
|
||||
'purchase_database_permission_recheck_denied',
|
||||
'purchase_currency_field_missing_blocked',
|
||||
'purchase_currency_crosswalk_unapproved_blocked',
|
||||
'purchase_row_scope_denied',
|
||||
'purchase_runtime_recheck_blocked', 'purchase_transaction_rollback',
|
||||
'purchase_idempotency_replay', 'purchase_idempotency_conflict',
|
||||
'purchase_audit_correlated'
|
||||
)
|
||||
} else {
|
||||
@(
|
||||
'leave_natural_language_resolution',
|
||||
'leave_multi_day_calendar_resolution',
|
||||
'leave_resolution_proof_bypass_blocked', 'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked',
|
||||
'leave_time_segment_required_blocked',
|
||||
'leave_local_time_zone_rejected',
|
||||
'leave_other_employee_denied', 'leave_create_draft_commit',
|
||||
'leave_permission_denied', 'leave_database_permission_recheck_denied',
|
||||
'leave_submit_separate_confirmation', 'leave_overlap_blocked',
|
||||
'leave_stale_flow_type_blocked',
|
||||
'leave_runtime_recheck_blocked', 'leave_transaction_rollback',
|
||||
'leave_idempotency_replay', 'leave_idempotency_conflict',
|
||||
'leave_audit_correlated'
|
||||
)
|
||||
}
|
||||
$placeholderHash = 'REPLACE_WITH_SHA256'
|
||||
$observedAt = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
$expectedResults = @{
|
||||
purchase_unique_match_commit = 'purchase_document_created'
|
||||
purchase_ambiguous_match_blocked = 'purchase_match_invalid'
|
||||
purchase_overallocation_blocked = 'purchase_match_invalid'
|
||||
purchase_permission_denied = 'command_access_denied'
|
||||
purchase_database_permission_recheck_denied = 'purchase_write_permission_denied'
|
||||
purchase_currency_field_missing_blocked = 'purchase_currency_field_not_configured'
|
||||
purchase_currency_crosswalk_unapproved_blocked = 'purchase_currency_crosswalk_not_approved'
|
||||
purchase_row_scope_denied = 'purchase_row_scope_denied'
|
||||
purchase_runtime_recheck_blocked = 'purchase_source_changed'
|
||||
purchase_transaction_rollback = 'purchase_legacy_create_failed'
|
||||
purchase_idempotency_replay = 'purchase_document_created'
|
||||
purchase_idempotency_conflict = 'idempotency_key_conflict'
|
||||
purchase_audit_correlated = 'purchase_document_created'
|
||||
leave_natural_language_resolution = 'leave_intent_resolved'
|
||||
leave_multi_day_calendar_resolution = 'leave_intent_resolved'
|
||||
leave_resolution_proof_bypass_blocked = 'leave_resolution_invalid'
|
||||
leave_ambiguous_type_blocked = 'leave_resolution_invalid'
|
||||
leave_ambiguous_flow_type_blocked = 'leave_resolution_invalid'
|
||||
leave_time_segment_required_blocked = 'leave_resolution_invalid'
|
||||
leave_local_time_zone_rejected = 'input_schema_violation'
|
||||
leave_other_employee_denied = 'leave_resolution_invalid'
|
||||
leave_permission_denied = 'command_access_denied'
|
||||
leave_database_permission_recheck_denied = 'leave_write_permission_denied'
|
||||
leave_create_draft_commit = 'leave_draft_created'
|
||||
leave_submit_separate_confirmation = 'leave_submitted'
|
||||
leave_overlap_blocked = 'leave_request_invalid'
|
||||
leave_stale_flow_type_blocked = 'leave_request_changed'
|
||||
leave_runtime_recheck_blocked = 'leave_request_changed'
|
||||
leave_transaction_rollback = 'leave_legacy_create_failed'
|
||||
leave_idempotency_replay = 'leave_draft_created'
|
||||
leave_idempotency_conflict = 'idempotency_key_conflict'
|
||||
leave_audit_correlated = 'leave_draft_created'
|
||||
}
|
||||
$expectedIssues = @{
|
||||
leave_ambiguous_type_blocked = 'leave_type_ambiguous'
|
||||
leave_ambiguous_flow_type_blocked = 'leave_flow_type_ambiguous'
|
||||
leave_time_segment_required_blocked = 'leave_time_segment_required'
|
||||
leave_other_employee_denied = 'leave_employee_reference_unsupported'
|
||||
}
|
||||
$cases = @($codes | ForEach-Object {
|
||||
$caseCode = [string]$_
|
||||
$commandName = if ($caseCode.StartsWith('purchase_', [StringComparison]::Ordinal)) {
|
||||
'purchase.invoice.create'
|
||||
} elseif ($caseCode -in @(
|
||||
'leave_natural_language_resolution',
|
||||
'leave_multi_day_calendar_resolution',
|
||||
'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked',
|
||||
'leave_time_segment_required_blocked',
|
||||
'leave_other_employee_denied')) {
|
||||
'hr.leave.resolve'
|
||||
} elseif ($caseCode -eq 'leave_submit_separate_confirmation') {
|
||||
'hr.leave.submit'
|
||||
} else {
|
||||
'hr.leave.create'
|
||||
}
|
||||
$planRequired = $caseCode -notin @(
|
||||
'purchase_permission_denied',
|
||||
'leave_permission_denied',
|
||||
'leave_local_time_zone_rejected')
|
||||
$planVersion = if (-not $planRequired) {
|
||||
$null
|
||||
} elseif ($commandName -eq 'purchase.invoice.create') {
|
||||
'1.4'
|
||||
} elseif ($commandName -eq 'hr.leave.resolve') {
|
||||
'1.4'
|
||||
} elseif ($commandName -eq 'hr.leave.submit') {
|
||||
'1.0'
|
||||
} else {
|
||||
'1.2'
|
||||
}
|
||||
$planRisk = if (-not $planRequired) {
|
||||
$null
|
||||
} elseif ($commandName -eq 'hr.leave.resolve') {
|
||||
'draft'
|
||||
} else {
|
||||
'write'
|
||||
}
|
||||
$issueCode = $null
|
||||
if ($expectedIssues.ContainsKey($caseCode)) {
|
||||
$issueCode = [string]$expectedIssues[$caseCode]
|
||||
}
|
||||
[ordered]@{
|
||||
caseCode = $caseCode
|
||||
uatAuthorizationSourceSha256 =
|
||||
'REPLACE_WITH_UAT_AUTHORIZATION_SOURCE_SHA256'
|
||||
uatAuthorizationContentSha256 =
|
||||
'REPLACE_WITH_UAT_AUTHORIZATION_CONTENT_SHA256'
|
||||
uatAuthorizationIdSha256 =
|
||||
'REPLACE_WITH_UAT_AUTHORIZATION_ID_SHA256'
|
||||
uatTokenSha256 = 'REPLACE_WITH_UNIQUE_UAT_CASE_TOKEN_SHA256'
|
||||
runtimeCliVersion = 'REPLACE_WITH_RUNTIME_CLI_VERSION'
|
||||
runtimeCliSha256 = 'REPLACE_WITH_RUNTIME_CLI_SHA256'
|
||||
runtimeCliSignerThumbprint =
|
||||
'REPLACE_WITH_RUNTIME_CLI_SIGNER_THUMBPRINT'
|
||||
passed = $false
|
||||
correlationId = 'REPLACE_WITH_CORRELATION_ID'
|
||||
contextCorrelationBound = $false
|
||||
commandName = $commandName
|
||||
planCommandVersion = $planVersion
|
||||
planModuleCode = $(if ($planRequired) { 'REPLACE_WITH_MODULE_CODE' } else { $null })
|
||||
planRisk = $planRisk
|
||||
accountBookSha256 = $placeholderHash
|
||||
subSystemIdSha256 = $placeholderHash
|
||||
userIdSha256 = $placeholderHash
|
||||
userNameSha256 = $placeholderHash
|
||||
databaseScopeFingerprint = $placeholderHash
|
||||
isAdministrator = $false
|
||||
inputFingerprintSha256 = $placeholderHash
|
||||
planFingerprintSha256 = $null
|
||||
resultCode = [string]$expectedResults[$caseCode]
|
||||
issueCode = $issueCode
|
||||
recordIdSha256 = $null
|
||||
transactionEvidenceIdSha256 = $null
|
||||
businessAuditIdSha256 = $null
|
||||
idempotencyKeySha256 = $null
|
||||
businessMutationCount = 0
|
||||
replayed = $false
|
||||
nativeConfirmationObserved = $false
|
||||
auditEventCount = 1
|
||||
sourceDocumentSetSha256 = $null
|
||||
sourceDocumentPreprocessContracts = @()
|
||||
sourceDocumentInputFingerprintBound = $false
|
||||
sourceDocumentWritePayloadBound = $false
|
||||
sourceDocumentAuditCount = 0
|
||||
observedAtUtc = $observedAt
|
||||
}
|
||||
})
|
||||
$target = [IO.Path]::GetFullPath($OutputPath)
|
||||
$directory = [IO.Path]::GetDirectoryName($target)
|
||||
if ([string]::IsNullOrWhiteSpace($directory) -or -not [IO.Directory]::Exists($directory) -or
|
||||
[IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) {
|
||||
throw 'Output must be a new file in an existing directory.'
|
||||
}
|
||||
$utf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$body = $utf8.GetBytes(($cases | ConvertTo-Json -Depth 5) + [Environment]::NewLine)
|
||||
$stream = [IO.File]::Open(
|
||||
$target, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try { $stream.Write($body, 0, $body.Length); $stream.Flush() }
|
||||
finally { $stream.Dispose() }
|
||||
|
||||
[ordered]@{
|
||||
outputFile = $target
|
||||
workflow = $Workflow
|
||||
caseCount = $cases.Count
|
||||
ready = $false
|
||||
nextStep = 'Replace every placeholder with observed, redacted evidence; keep passed=false until independently reviewed.'
|
||||
} | ConvertTo-Json -Depth 4
|
||||
@@ -0,0 +1,297 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][ValidateSet('purchase', 'leave')][string]$Workflow,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')][string]$ModuleCode,
|
||||
[Parameter(Mandatory = $true)][string]$AccountBook,
|
||||
[Parameter(Mandatory = $true)][string]$SubSystemId,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{40}$')][string]$SourceCommit,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Fa-f0-9]{64}$')][string]$PackageSha256,
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$')][string]$RolloutCustomerId,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{8,128}$')][string]$EnvironmentId,
|
||||
[Parameter(Mandatory = $true)][ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')][string]$TestedBy,
|
||||
[Parameter(Mandatory = $true)][string]$CasesFile,
|
||||
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
|
||||
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
||||
[Parameter(Mandatory = $true)][string]$OutputPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-RegularFile([string]$Path, [long]$MaximumBytes, [string]$Label) {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) { throw "$Label file does not exist." }
|
||||
$info = Get-Item -LiteralPath $full -Force
|
||||
if ($info.Length -le 0 -or $info.Length -gt $MaximumBytes -or
|
||||
(($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
throw "$Label must be a non-empty ordinary file within the size limit."
|
||||
}
|
||||
return $full
|
||||
}
|
||||
|
||||
function Get-Sha256([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($names.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) { if ($names -cnotcontains $name) { return $false } }
|
||||
return $true
|
||||
}
|
||||
|
||||
$strictUtf8 = New-Object Text.UTF8Encoding($false, $true)
|
||||
$runtimePath = Get-RegularFile $RuntimeConfigurationFile 64KB 'Runtime configuration'
|
||||
$casesPath = Get-RegularFile $CasesFile 2MB 'Cases'
|
||||
$uatAuthorizationPath = Get-RegularFile `
|
||||
$UatAuthorizationFile 512KB 'UAT authorization'
|
||||
$cliPath = Get-RegularFile $VerifierCliPath 64MB 'Verifier CLI'
|
||||
if ([IO.Path]::GetFileName($cliPath) -ne 'lserp-cli.exe') {
|
||||
throw 'Verifier CLI filename must be lserp-cli.exe.'
|
||||
}
|
||||
$runtimeBytes = [IO.File]::ReadAllBytes($runtimePath)
|
||||
$runtimeHash = Get-Sha256 $runtimeBytes
|
||||
$uatAuthorizationHash = Get-Sha256 `
|
||||
([IO.File]::ReadAllBytes($uatAuthorizationPath))
|
||||
$cliHash = Get-Sha256 ([IO.File]::ReadAllBytes($cliPath))
|
||||
$uatOutput = @(& $cliPath 'acceptance' 'verify-uat-authorization' `
|
||||
'--input' $uatAuthorizationPath '--json' 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'UAT authorization failed final CLI verification.'
|
||||
}
|
||||
try {
|
||||
$uatVerification = (($uatOutput | ForEach-Object { [string]$_ }) -join `
|
||||
[Environment]::NewLine) | ConvertFrom-Json
|
||||
}
|
||||
catch { throw 'UAT authorization verifier returned invalid JSON.' }
|
||||
if (-not (Test-ExactProperties $uatVerification @('ok', 'correlationId', 'data')) -or
|
||||
-not (Test-ExactProperties $uatVerification.data.erpScope @(
|
||||
'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256',
|
||||
'databaseScopeFingerprint', 'isAdministrator')) -or
|
||||
-not (Test-ExactProperties $uatVerification.data.runtimeCli @(
|
||||
'fileName', 'version', 'sha256', 'signerThumbprint',
|
||||
'requiresElevation', 'bridgeOnly', 'databaseDirectAccess',
|
||||
'sessionSource')) -or
|
||||
-not (Test-ExactProperties $uatVerification.data.verifierCli @(
|
||||
'fileName', 'sha256', 'signerThumbprint',
|
||||
'requiresElevation')) -or
|
||||
$uatVerification.ok -ne $true -or
|
||||
$uatVerification.data.packageType -cne 'workflow_write_uat_authorization' -or
|
||||
$uatVerification.data.schemaVersion -cne '1.2' -or
|
||||
$uatVerification.data.sourceSha256 -cne $uatAuthorizationHash -or
|
||||
$uatVerification.data.runtimeConfigurationSha256 -cne $runtimeHash -or
|
||||
$uatVerification.data.sourceCommit -cne $SourceCommit.ToLowerInvariant() -or
|
||||
$uatVerification.data.packageSha256 -cne $PackageSha256.ToLowerInvariant() -or
|
||||
$uatVerification.data.customerId -cne $RolloutCustomerId -or
|
||||
$uatVerification.data.environmentId -cne $EnvironmentId -or
|
||||
$uatVerification.data.erpScope.accountBook -cne $AccountBook -or
|
||||
$uatVerification.data.erpScope.subSystemId -cne $SubSystemId -or
|
||||
$uatVerification.data.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or
|
||||
[string]$uatVerification.data.runtimeCli.version -cnotmatch
|
||||
'^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$' -or
|
||||
[string]$uatVerification.data.runtimeCli.sha256 -cnotmatch
|
||||
'^[a-f0-9]{64}$' -or
|
||||
[string]$uatVerification.data.runtimeCli.signerThumbprint -cnotmatch
|
||||
'^[A-F0-9]{40}$' -or
|
||||
$uatVerification.data.runtimeCli.requiresElevation -ne $false -or
|
||||
$uatVerification.data.runtimeCli.bridgeOnly -ne $true -or
|
||||
$uatVerification.data.runtimeCli.databaseDirectAccess -ne $false -or
|
||||
$uatVerification.data.runtimeCli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
$uatVerification.data.verifierCli.fileName -cne 'lserp-cli.exe' -or
|
||||
$uatVerification.data.verifierCli.sha256 -cne $cliHash -or
|
||||
$uatVerification.data.verifierCli.requiresElevation -ne $true -or
|
||||
$uatVerification.data.signatureVerified -ne $true -or
|
||||
$uatVerification.data.uatAuthorized -ne $true -or
|
||||
$uatVerification.data.productionReady -ne $false) {
|
||||
throw 'UAT authorization does not bind the final test scope and verifier.'
|
||||
}
|
||||
$authorizedWorkflows = @($uatVerification.data.workflows | Where-Object {
|
||||
[string]$_.workflow -ceq $Workflow -and
|
||||
[string]$_.moduleCode -ceq $ModuleCode
|
||||
})
|
||||
if ($authorizedWorkflows.Count -ne 1) {
|
||||
throw 'UAT authorization does not contain the exact workflow and module.'
|
||||
}
|
||||
$casesText = [IO.File]::ReadAllText($casesPath, $strictUtf8)
|
||||
$casesHash = Get-Sha256 ($strictUtf8.GetBytes($casesText))
|
||||
try { $cases = @($casesText | ConvertFrom-Json) }
|
||||
catch { throw 'Cases file must be a UTF-8 JSON array.' }
|
||||
if ($cases.Count -le 0 -or $cases.Count -gt 32 -or $casesText.TrimStart()[0] -ne '[') {
|
||||
throw 'Cases file must contain a JSON array with 1-32 items.'
|
||||
}
|
||||
$runtimeCliVersion = [string]$uatVerification.data.runtimeCli.version
|
||||
$runtimeCliSha256 = [string]$uatVerification.data.runtimeCli.sha256
|
||||
$runtimeCliSigner = [string]$uatVerification.data.runtimeCli.signerThumbprint
|
||||
foreach ($case in $cases) {
|
||||
if ([string]$case.runtimeCliVersion -cne $runtimeCliVersion -or
|
||||
[string]$case.runtimeCliSha256 -cne $runtimeCliSha256 -or
|
||||
[string]$case.runtimeCliSignerThumbprint -cne $runtimeCliSigner) {
|
||||
throw 'A projected case does not bind the authorized runtime CLI.'
|
||||
}
|
||||
}
|
||||
|
||||
$testedAt = [DateTime]::UtcNow
|
||||
$content = [ordered]@{
|
||||
evidenceType = 'workflow_write_integration'
|
||||
workflow = $Workflow
|
||||
moduleCode = $ModuleCode
|
||||
erpScope = [ordered]@{
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
}
|
||||
sourceCommit = $SourceCommit.ToLowerInvariant()
|
||||
packageSha256 = $PackageSha256.ToLowerInvariant()
|
||||
runtimeConfigurationSha256 = $runtimeHash
|
||||
runtimeCli = [ordered]@{
|
||||
fileName = 'lserp-agent-cli.exe'
|
||||
version = $runtimeCliVersion
|
||||
sha256 = $runtimeCliSha256
|
||||
signerThumbprint = $runtimeCliSigner
|
||||
requiresElevation = $false
|
||||
bridgeOnly = $true
|
||||
databaseDirectAccess = $false
|
||||
sessionSource = 'current_logged_in_erp_process'
|
||||
}
|
||||
uatAuthorizationSourceSha256 = [string]$uatVerification.data.sourceSha256
|
||||
uatAuthorizationContentSha256 = [string]$uatVerification.data.contentSha256
|
||||
uatAuthorizationIdSha256 = [string]$uatVerification.data.authorizationIdSha256
|
||||
environmentId = $EnvironmentId
|
||||
testedAtUtc = $testedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
testedBy = $TestedBy
|
||||
cases = $cases
|
||||
}
|
||||
$canonical = $content | ConvertTo-Json -Compress -Depth 12
|
||||
$contentHash = Get-Sha256 $strictUtf8.GetBytes($canonical)
|
||||
$envelope = [ordered]@{
|
||||
schemaVersion = '1.6'
|
||||
contentSha256 = $contentHash
|
||||
content = $content
|
||||
}
|
||||
$body = $strictUtf8.GetBytes(($envelope | ConvertTo-Json -Depth 12) + [Environment]::NewLine)
|
||||
$bodyHash = Get-Sha256 $body
|
||||
$target = [IO.Path]::GetFullPath($OutputPath)
|
||||
$directory = [IO.Path]::GetDirectoryName($target)
|
||||
if ([string]::IsNullOrWhiteSpace($directory) -or -not [IO.Directory]::Exists($directory) -or
|
||||
[IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) {
|
||||
throw 'Output must be a new file in an existing directory.'
|
||||
}
|
||||
$temporary = Join-Path $directory ('.lserp-write-evidence-' + [Guid]::NewGuid().ToString('N') + '.tmp')
|
||||
$published = $false
|
||||
try {
|
||||
$stream = [IO.File]::Open(
|
||||
$temporary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try { $stream.Write($body, 0, $body.Length); $stream.Flush() }
|
||||
finally { $stream.Dispose() }
|
||||
|
||||
$arguments = @(
|
||||
'adapters', 'verify-write-integration-evidence', '--input', $temporary,
|
||||
'--workflow', $Workflow, '--module', $ModuleCode,
|
||||
'--account-book', $AccountBook, '--subsystem', $SubSystemId,
|
||||
'--runtime-sha256', $runtimeHash,
|
||||
'--source-commit', $SourceCommit.ToLowerInvariant(),
|
||||
'--package-sha256', $PackageSha256.ToLowerInvariant()
|
||||
)
|
||||
$verificationOutput = @(& $cliPath @arguments 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Generated write integration evidence failed CLI verification.' }
|
||||
$verificationText = (($verificationOutput | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
||||
try { $verification = $verificationText | ConvertFrom-Json }
|
||||
catch { throw 'Verifier CLI returned invalid JSON.' }
|
||||
$expectedData = @(
|
||||
'evidenceType', 'schemaVersion', 'contentSha256', 'workflow', 'moduleCode',
|
||||
'erpScope', 'sourceCommit', 'packageSha256', 'runtimeConfigurationSha256',
|
||||
'runtimeCli',
|
||||
'uatAuthorizationSourceSha256', 'uatAuthorizationContentSha256',
|
||||
'uatAuthorizationIdSha256',
|
||||
'environmentId', 'testedAtUtc', 'testedBy', 'caseCount', 'verified',
|
||||
'registrationReady'
|
||||
)
|
||||
if (-not (Test-ExactProperties $verification @('ok', 'correlationId', 'data')) -or
|
||||
-not (Test-ExactProperties $verification.data $expectedData) -or
|
||||
-not (Test-ExactProperties $verification.data.erpScope @(
|
||||
'accountBook', 'subSystemId', 'userIdSha256', 'userNameSha256',
|
||||
'databaseScopeFingerprint', 'isAdministrator')) -or
|
||||
-not (Test-ExactProperties $verification.data.runtimeCli @(
|
||||
'fileName', 'version', 'sha256', 'signerThumbprint',
|
||||
'requiresElevation', 'bridgeOnly', 'databaseDirectAccess',
|
||||
'sessionSource')) -or
|
||||
$verification.ok -ne $true -or $verification.data.verified -ne $true -or
|
||||
$verification.data.schemaVersion -ne '1.6' -or
|
||||
$verification.data.registrationReady -ne $false -or
|
||||
$verification.data.contentSha256 -ne $contentHash -or
|
||||
$verification.data.workflow -ne $Workflow -or
|
||||
$verification.data.moduleCode -ne $ModuleCode -or
|
||||
$verification.data.erpScope.accountBook -ne $AccountBook -or
|
||||
$verification.data.erpScope.subSystemId -ne $SubSystemId -or
|
||||
$verification.data.erpScope.userIdSha256 -cne
|
||||
$uatVerification.data.erpScope.userIdSha256 -or
|
||||
$verification.data.erpScope.userNameSha256 -cne
|
||||
$uatVerification.data.erpScope.userNameSha256 -or
|
||||
$verification.data.erpScope.databaseScopeFingerprint -cne
|
||||
$uatVerification.data.erpScope.databaseScopeFingerprint -or
|
||||
$verification.data.erpScope.isAdministrator -ne
|
||||
$uatVerification.data.erpScope.isAdministrator -or
|
||||
$verification.data.runtimeConfigurationSha256 -ne $runtimeHash -or
|
||||
$verification.data.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or
|
||||
$verification.data.runtimeCli.version -cne $runtimeCliVersion -or
|
||||
$verification.data.runtimeCli.sha256 -cne $runtimeCliSha256 -or
|
||||
$verification.data.runtimeCli.signerThumbprint -cne $runtimeCliSigner -or
|
||||
$verification.data.runtimeCli.requiresElevation -ne $false -or
|
||||
$verification.data.runtimeCli.bridgeOnly -ne $true -or
|
||||
$verification.data.runtimeCli.databaseDirectAccess -ne $false -or
|
||||
$verification.data.runtimeCli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
$verification.data.uatAuthorizationSourceSha256 -ne
|
||||
$uatVerification.data.sourceSha256 -or
|
||||
$verification.data.uatAuthorizationContentSha256 -ne
|
||||
$uatVerification.data.contentSha256 -or
|
||||
$verification.data.uatAuthorizationIdSha256 -ne
|
||||
$uatVerification.data.authorizationIdSha256 -or
|
||||
$verification.data.sourceCommit -ne $SourceCommit.ToLowerInvariant() -or
|
||||
$verification.data.packageSha256 -ne $PackageSha256.ToLowerInvariant() -or
|
||||
[int]$verification.data.caseCount -ne $cases.Count -or
|
||||
(Get-Sha256 ([IO.File]::ReadAllBytes($temporary))) -ne $bodyHash) {
|
||||
throw 'Verifier CLI response is not bound to the generated evidence.'
|
||||
}
|
||||
if ((Get-Sha256 ([IO.File]::ReadAllBytes($uatAuthorizationPath))) -ne
|
||||
$uatAuthorizationHash -or
|
||||
(Get-Sha256 ([IO.File]::ReadAllBytes($runtimePath))) -ne $runtimeHash -or
|
||||
(Get-Sha256 ([IO.File]::ReadAllBytes($casesPath))) -ne $casesHash -or
|
||||
(Get-Sha256 ([IO.File]::ReadAllBytes($cliPath))) -ne $cliHash) {
|
||||
throw 'A locked evidence input changed during final verification.'
|
||||
}
|
||||
[IO.File]::Move($temporary, $target)
|
||||
$published = $true
|
||||
}
|
||||
finally {
|
||||
if (-not $published -and [IO.File]::Exists($temporary)) {
|
||||
[IO.File]::Delete($temporary)
|
||||
}
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
outputFile = $target
|
||||
workflow = $Workflow
|
||||
moduleCode = $ModuleCode
|
||||
accountBook = $AccountBook
|
||||
subSystemId = $SubSystemId
|
||||
sourceCommit = $SourceCommit.ToLowerInvariant()
|
||||
packageSha256 = $PackageSha256.ToLowerInvariant()
|
||||
runtimeConfigurationSha256 = $runtimeHash
|
||||
runtimeCliVersion = $runtimeCliVersion
|
||||
runtimeCliSha256 = $runtimeCliSha256
|
||||
runtimeCliSignerThumbprint = $runtimeCliSigner
|
||||
uatAuthorizationSourceSha256 = [string]$uatVerification.data.sourceSha256
|
||||
uatAuthorizationContentSha256 = [string]$uatVerification.data.contentSha256
|
||||
uatAuthorizationIdSha256 = [string]$uatVerification.data.authorizationIdSha256
|
||||
contentSha256 = $contentHash
|
||||
caseCount = $cases.Count
|
||||
testedAtUtc = $testedAt.ToString('o')
|
||||
nextStep = 'Run lserp-cli adapters verify-write-integration-evidence, then sign the workflow acceptance manifest.'
|
||||
} | ConvertTo-Json -Depth 5
|
||||
@@ -0,0 +1,931 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]{7,63}$')]
|
||||
[string]$CampaignId,
|
||||
|
||||
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
|
||||
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeCliPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedUatAuthorizationSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedVerifierCliSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
|
||||
[string]$ExpectedRuntimeCliVersion,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedRuntimeCliSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedVerifierSignerThumbprint,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$ExpectedRuntimeSignerThumbprint,
|
||||
[string]$CaseCatalogFile = (Join-Path $PSScriptRoot `
|
||||
'workflow-write-uat-case-catalog.v1.json'),
|
||||
[Parameter(Mandatory = $true)][string]$OutputRoot,
|
||||
[ValidateRange(1000, 60000)][int]$CliTimeoutMilliseconds = 30000
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
throw 'workflow_uat_campaign_failed:windows_powershell_51_required'
|
||||
}
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'workflow_uat_campaign_failed:elevated_operator_required'
|
||||
}
|
||||
|
||||
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$maximumResponseCharacters = 4 * 1024 * 1024
|
||||
$locks = New-Object System.Collections.Generic.List[IO.FileStream]
|
||||
$campaignDirectory = $null
|
||||
$expectedCaseCatalogSha256 = `
|
||||
'23eb6c4f308d4904bf3920ed37499f05521beebde9422026f9732983c16002d5'
|
||||
|
||||
function Throw-CampaignError([string]$Code) {
|
||||
throw ('workflow_uat_campaign_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
if ($names.Count -ne $Expected.Count) { return $false }
|
||||
foreach ($name in $Expected) {
|
||||
if ($names -cnotcontains $name) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-ExactStringArray([object[]]$Actual, [string[]]$Expected) {
|
||||
$values = @($Actual)
|
||||
if ($values.Count -ne $Expected.Count) { return $false }
|
||||
for ($index = 0; $index -lt $Expected.Count; $index++) {
|
||||
if ([string]$values[$index] -cne $Expected[$index]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CampaignError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
||||
Throw-CampaignError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Open-LockedRegularFile(
|
||||
[string]$Path,
|
||||
[long]$MaximumBytes,
|
||||
[string]$ExpectedFileName,
|
||||
[string]$Code
|
||||
) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if (-not [IO.File]::Exists($full)) { Throw-CampaignError $Code }
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
||||
(-not [string]::IsNullOrWhiteSpace($ExpectedFileName) -and
|
||||
[IO.Path]::GetFileName($full) -cne $ExpectedFileName)) {
|
||||
Throw-CampaignError $Code
|
||||
}
|
||||
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
|
||||
$stream = [IO.File]::Open(
|
||||
$full, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
||||
$script:locks.Add($stream)
|
||||
return [pscustomobject]@{ Path = $full; Stream = $stream }
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
||||
Throw-CampaignError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Hex([byte[]]$Bytes) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-LockedSha256([IO.FileStream]$Stream) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Stream.Position = 0
|
||||
$value = ([BitConverter]::ToString($sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
||||
$Stream.Position = 0
|
||||
return $value
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Read-LockedJson([object]$LockedFile, [string]$Code) {
|
||||
try {
|
||||
$LockedFile.Stream.Position = 0
|
||||
$reader = New-Object IO.StreamReader(
|
||||
$LockedFile.Stream, $utf8, $true, 4096, $true)
|
||||
try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
||||
$LockedFile.Stream.Position = 0
|
||||
return $text | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
||||
Throw-CampaignError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-UnixSeconds([object]$Value) {
|
||||
$timestamp = ([DateTimeOffset]$Value).ToUniversalTime()
|
||||
$epoch = [DateTimeOffset]::new(
|
||||
1970, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
||||
return [int64][Math]::Floor(($timestamp - $epoch).TotalSeconds)
|
||||
}
|
||||
|
||||
function ConvertTo-WindowsProcessArgument([string]$Value) {
|
||||
if ($null -eq $Value -or $Value.Length -eq 0) { return '""' }
|
||||
if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) { return $Value }
|
||||
$builder = New-Object Text.StringBuilder
|
||||
[void]$builder.Append([char]34)
|
||||
$slashes = 0
|
||||
foreach ($character in $Value.ToCharArray()) {
|
||||
if ([int]$character -eq 92) { $slashes++; continue }
|
||||
if ([int]$character -eq 34) {
|
||||
for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) {
|
||||
[void]$builder.Append([char]92)
|
||||
}
|
||||
[void]$builder.Append([char]34)
|
||||
}
|
||||
else {
|
||||
for ($index = 0; $index -lt $slashes; $index++) {
|
||||
[void]$builder.Append([char]92)
|
||||
}
|
||||
[void]$builder.Append($character)
|
||||
}
|
||||
$slashes = 0
|
||||
}
|
||||
for ($index = 0; $index -lt ($slashes * 2); $index++) {
|
||||
[void]$builder.Append([char]92)
|
||||
}
|
||||
[void]$builder.Append([char]34)
|
||||
return $builder.ToString()
|
||||
}
|
||||
|
||||
function Invoke-TrustedCli([string]$CliPath, [string[]]$Arguments) {
|
||||
$process = New-Object Diagnostics.Process
|
||||
try {
|
||||
$start = New-Object Diagnostics.ProcessStartInfo
|
||||
$start.FileName = $CliPath
|
||||
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($CliPath)
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
$start.RedirectStandardInput = $true
|
||||
$start.StandardOutputEncoding = $utf8
|
||||
$start.StandardErrorEncoding = $utf8
|
||||
$start.Arguments = (($Arguments | ForEach-Object {
|
||||
ConvertTo-WindowsProcessArgument ([string]$_)
|
||||
}) -join ' ')
|
||||
$process.StartInfo = $start
|
||||
if (-not $process.Start()) { Throw-CampaignError 'cli_process_start_failed' }
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
$process.StandardInput.Close()
|
||||
if (-not $process.WaitForExit($CliTimeoutMilliseconds)) {
|
||||
try { $process.Kill() } catch { }
|
||||
Throw-CampaignError 'cli_timeout'
|
||||
}
|
||||
$process.WaitForExit()
|
||||
$stdout = $stdoutTask.Result
|
||||
$stderr = $stderrTask.Result
|
||||
if ($process.ExitCode -ne 0 -or
|
||||
[string]::IsNullOrWhiteSpace($stdout) -or
|
||||
-not [string]::IsNullOrWhiteSpace($stderr) -or
|
||||
$stdout.Length -gt $maximumResponseCharacters) {
|
||||
Throw-CampaignError 'uat_authorization_verification_failed'
|
||||
}
|
||||
try { $envelope = $stdout | ConvertFrom-Json }
|
||||
catch { Throw-CampaignError 'cli_response_invalid' }
|
||||
if (-not (Test-ExactProperties $envelope @('ok', 'correlationId', 'data')) -or
|
||||
$envelope.ok -ne $true -or
|
||||
([string]$envelope.correlationId) -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') {
|
||||
Throw-CampaignError 'cli_response_invalid'
|
||||
}
|
||||
return $envelope.data
|
||||
}
|
||||
finally { $process.Dispose() }
|
||||
}
|
||||
|
||||
function Assert-RestrictedDirectoryAcl([string]$Path) {
|
||||
try {
|
||||
$sections = [Security.AccessControl.AccessControlSections]::All
|
||||
$acl = [IO.Directory]::GetAccessControl($Path, $sections)
|
||||
$owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
$currentSid = $identity.User.Value
|
||||
$systemSid = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid, $null).Value
|
||||
$rules = @($acl.GetAccessRules(
|
||||
$true, $false, [Security.Principal.SecurityIdentifier]))
|
||||
$seen = @{}
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne
|
||||
[Security.AccessControl.AccessControlType]::Allow -or
|
||||
$sid -cnotin @($currentSid, $systemSid) -or
|
||||
(($rule.FileSystemRights -band
|
||||
[Security.AccessControl.FileSystemRights]::FullControl) -ne
|
||||
[Security.AccessControl.FileSystemRights]::FullControl) -or
|
||||
$seen.ContainsKey($sid)) {
|
||||
Throw-CampaignError 'campaign_directory_acl_invalid'
|
||||
}
|
||||
$seen[$sid] = $true
|
||||
}
|
||||
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
|
||||
if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid -or
|
||||
$rules.Count -ne 2 -or -not $seen.ContainsKey($currentSid) -or
|
||||
-not $seen.ContainsKey($systemSid) -or
|
||||
$sddl -cnotmatch
|
||||
'S:.*\(ML;(?=[A-Z]*OI)(?=[A-Z]*CI)[A-Z]*;NW;;;HI\)') {
|
||||
Throw-CampaignError 'campaign_directory_acl_invalid'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
||||
Throw-CampaignError 'campaign_directory_acl_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function New-RestrictedCampaignDirectory([string]$Root, [string]$Name) {
|
||||
$fullRoot = [IO.Path]::GetFullPath($Root)
|
||||
if (-not [IO.Directory]::Exists($fullRoot)) {
|
||||
Throw-CampaignError 'output_root_invalid'
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $fullRoot 'output_root_invalid'
|
||||
$target = Join-Path $fullRoot $Name
|
||||
if ([IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) {
|
||||
Throw-CampaignError 'campaign_directory_exists'
|
||||
}
|
||||
[IO.Directory]::CreateDirectory($target) | Out-Null
|
||||
$currentUser = $identity.User
|
||||
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
|
||||
$security = New-Object Security.AccessControl.DirectorySecurity
|
||||
$security.SetOwner($currentUser)
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor
|
||||
[Security.AccessControl.InheritanceFlags]::ObjectInherit
|
||||
foreach ($principalSid in @($currentUser, $localSystem)) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$principalSid,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
$inheritance,
|
||||
[Security.AccessControl.PropagationFlags]::None,
|
||||
[Security.AccessControl.AccessControlType]::Allow)
|
||||
$security.AddAccessRule($rule)
|
||||
}
|
||||
[IO.Directory]::SetAccessControl($target, $security)
|
||||
& "$env:SystemRoot\System32\icacls.exe" `
|
||||
$target '/setintegritylevel' '(OI)(CI)H' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { Throw-CampaignError 'campaign_directory_acl_invalid' }
|
||||
Assert-RestrictedDirectoryAcl $target
|
||||
return $target
|
||||
}
|
||||
|
||||
function Write-NewJson([string]$Path, [object]$Value, [int]$Depth) {
|
||||
$bytes = $utf8.GetBytes(($Value | ConvertTo-Json -Depth $Depth) + [Environment]::NewLine)
|
||||
$stream = [IO.File]::Open(
|
||||
$Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() }
|
||||
finally { $stream.Dispose() }
|
||||
}
|
||||
|
||||
$purchaseCases = @(
|
||||
'purchase_unique_match_commit', 'purchase_ambiguous_match_blocked',
|
||||
'purchase_overallocation_blocked', 'purchase_permission_denied',
|
||||
'purchase_database_permission_recheck_denied',
|
||||
'purchase_currency_field_missing_blocked',
|
||||
'purchase_currency_crosswalk_unapproved_blocked',
|
||||
'purchase_row_scope_denied', 'purchase_runtime_recheck_blocked',
|
||||
'purchase_transaction_rollback', 'purchase_idempotency_replay',
|
||||
'purchase_idempotency_conflict', 'purchase_audit_correlated'
|
||||
)
|
||||
$leaveCases = @(
|
||||
'leave_natural_language_resolution', 'leave_multi_day_calendar_resolution',
|
||||
'leave_resolution_proof_bypass_blocked', 'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked', 'leave_time_segment_required_blocked',
|
||||
'leave_local_time_zone_rejected', 'leave_other_employee_denied',
|
||||
'leave_permission_denied', 'leave_database_permission_recheck_denied',
|
||||
'leave_create_draft_commit', 'leave_submit_separate_confirmation',
|
||||
'leave_overlap_blocked', 'leave_stale_flow_type_blocked',
|
||||
'leave_runtime_recheck_blocked', 'leave_transaction_rollback',
|
||||
'leave_idempotency_replay', 'leave_idempotency_conflict',
|
||||
'leave_audit_correlated'
|
||||
)
|
||||
$executeCases = @(
|
||||
'purchase_unique_match_commit',
|
||||
'purchase_database_permission_recheck_denied',
|
||||
'purchase_currency_field_missing_blocked',
|
||||
'purchase_currency_crosswalk_unapproved_blocked',
|
||||
'purchase_row_scope_denied', 'purchase_runtime_recheck_blocked',
|
||||
'purchase_transaction_rollback', 'purchase_idempotency_replay',
|
||||
'purchase_idempotency_conflict',
|
||||
'leave_database_permission_recheck_denied', 'leave_create_draft_commit',
|
||||
'leave_submit_separate_confirmation', 'leave_stale_flow_type_blocked',
|
||||
'leave_runtime_recheck_blocked', 'leave_transaction_rollback',
|
||||
'leave_idempotency_replay', 'leave_idempotency_conflict'
|
||||
)
|
||||
$derivedCases = @('purchase_audit_correlated', 'leave_audit_correlated')
|
||||
$postPlanStagingCases = @(
|
||||
'purchase_runtime_recheck_blocked',
|
||||
'leave_stale_flow_type_blocked',
|
||||
'leave_runtime_recheck_blocked'
|
||||
)
|
||||
|
||||
function Get-ExpectedCommand([string]$CaseCode) {
|
||||
if ($CaseCode.StartsWith('purchase_', [StringComparison]::Ordinal)) {
|
||||
return 'purchase.invoice.create'
|
||||
}
|
||||
if ($CaseCode -in @(
|
||||
'leave_natural_language_resolution',
|
||||
'leave_multi_day_calendar_resolution',
|
||||
'leave_ambiguous_type_blocked',
|
||||
'leave_ambiguous_flow_type_blocked',
|
||||
'leave_time_segment_required_blocked',
|
||||
'leave_other_employee_denied')) {
|
||||
return 'hr.leave.resolve'
|
||||
}
|
||||
if ($CaseCode -eq 'leave_submit_separate_confirmation') {
|
||||
return 'hr.leave.submit'
|
||||
}
|
||||
return 'hr.leave.create'
|
||||
}
|
||||
|
||||
function Get-AllowedCommands([string]$CommandName) {
|
||||
if ($CommandName -eq 'purchase.invoice.create') {
|
||||
return @('purchase.invoice.resolve', 'purchase.invoice.create')
|
||||
}
|
||||
if ($CommandName -eq 'hr.leave.create') {
|
||||
return @('hr.leave.resolve', 'hr.leave.create')
|
||||
}
|
||||
return @($CommandName)
|
||||
}
|
||||
|
||||
function Get-Dependency([string]$CaseCode) {
|
||||
if ($CaseCode -in @(
|
||||
'purchase_idempotency_replay', 'purchase_idempotency_conflict',
|
||||
'purchase_audit_correlated')) {
|
||||
return 'purchase_unique_match_commit'
|
||||
}
|
||||
if ($CaseCode -in @(
|
||||
'leave_submit_separate_confirmation', 'leave_idempotency_replay',
|
||||
'leave_idempotency_conflict', 'leave_audit_correlated')) {
|
||||
return 'leave_create_draft_commit'
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-IdempotencyPolicy([string]$CaseCode, [string]$CaptureMode) {
|
||||
if ($CaptureMode -eq 'plan_only') { return 'not_applicable' }
|
||||
if ($CaptureMode -eq 'derived_audit') { return 'derived_no_execute' }
|
||||
if ($CaseCode.EndsWith('_idempotency_replay', [StringComparison]::Ordinal)) {
|
||||
return 'reuse_dependency_key_and_input'
|
||||
}
|
||||
if ($CaseCode.EndsWith('_idempotency_conflict', [StringComparison]::Ordinal)) {
|
||||
return 'reuse_dependency_key_with_different_input'
|
||||
}
|
||||
return 'new_unique_key'
|
||||
}
|
||||
|
||||
function Test-SafeCatalogText([object]$Value, [int]$MaximumLength) {
|
||||
if ($null -eq $Value) { return $false }
|
||||
$text = [string]$Value
|
||||
if ([string]::IsNullOrWhiteSpace($text) -or
|
||||
$text.Length -gt $MaximumLength -or
|
||||
$text -cne $text.Trim() -or
|
||||
[Text.RegularExpressions.Regex]::IsMatch($text, '[\x00-\x1f\x7f]') -or
|
||||
[Text.RegularExpressions.Regex]::IsMatch(
|
||||
$text,
|
||||
'(?i)(?:https?|jdbc|file)://|\b(?:password|passwd|secret|api[_ -]?key|token)\b|\b(?:\d{1,3}\.){3}\d{1,3}\b|sk-[A-Za-z0-9_-]{8,}|\b(?:insert\s+into|update\s+\S+\s+set|delete\s+from|drop\s+table|truncate\s+table|alter\s+table)\b')) {
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Test-SafeCatalogTextArray(
|
||||
[object]$Value,
|
||||
[int]$MinimumCount,
|
||||
[int]$MaximumCount
|
||||
) {
|
||||
if ($null -eq $Value -or -not ($Value -is [Array])) { return $false }
|
||||
$items = @($Value)
|
||||
if ($items.Count -lt $MinimumCount -or $items.Count -gt $MaximumCount) {
|
||||
return $false
|
||||
}
|
||||
foreach ($item in $items) {
|
||||
if (-not (Test-SafeCatalogText $item 500)) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-CaseCatalog([object]$Catalog) {
|
||||
if (-not (Test-ExactProperties $Catalog @(
|
||||
'schemaVersion', 'packageType', 'safety', 'workflows')) -or
|
||||
[string]$Catalog.schemaVersion -cne '1.0' -or
|
||||
[string]$Catalog.packageType -cne 'workflow_write_uat_case_catalog' -or
|
||||
-not (Test-ExactProperties $Catalog.safety @(
|
||||
'productionUseProhibited',
|
||||
'automaticDatabaseOrConfigurationChanges',
|
||||
'approvedRestorePointRequired', 'containsCredentials',
|
||||
'containsBusinessIdentifiers', 'executableInstructionsIncluded')) -or
|
||||
$Catalog.safety.productionUseProhibited -ne $true -or
|
||||
$Catalog.safety.automaticDatabaseOrConfigurationChanges -ne $false -or
|
||||
$Catalog.safety.approvedRestorePointRequired -ne $true -or
|
||||
$Catalog.safety.containsCredentials -ne $false -or
|
||||
$Catalog.safety.containsBusinessIdentifiers -ne $false -or
|
||||
$Catalog.safety.executableInstructionsIncluded -ne $false) {
|
||||
Throw-CampaignError 'case_catalog_safety_invalid'
|
||||
}
|
||||
|
||||
$workflows = @($Catalog.workflows)
|
||||
if ($workflows.Count -ne 2) {
|
||||
Throw-CampaignError 'case_catalog_workflow_coverage_invalid'
|
||||
}
|
||||
$lookup = @{}
|
||||
for ($workflowIndex = 0; $workflowIndex -lt 2; $workflowIndex++) {
|
||||
$workflowName = if ($workflowIndex -eq 0) { 'purchase' } else { 'leave' }
|
||||
$expectedCases = if ($workflowName -eq 'purchase') {
|
||||
$purchaseCases
|
||||
} else { $leaveCases }
|
||||
$workflow = $workflows[$workflowIndex]
|
||||
if (-not (Test-ExactProperties $workflow @(
|
||||
'workflow', 'caseCount', 'cases')) -or
|
||||
[string]$workflow.workflow -cne $workflowName -or
|
||||
[int]$workflow.caseCount -ne $expectedCases.Count) {
|
||||
Throw-CampaignError 'case_catalog_workflow_contract_invalid'
|
||||
}
|
||||
$cases = @($workflow.cases)
|
||||
if ($cases.Count -ne $expectedCases.Count) {
|
||||
Throw-CampaignError 'case_catalog_case_coverage_invalid'
|
||||
}
|
||||
for ($index = 0; $index -lt $expectedCases.Count; $index++) {
|
||||
$caseCode = $expectedCases[$index]
|
||||
$case = $cases[$index]
|
||||
$expectedCommand = Get-ExpectedCommand $caseCode
|
||||
$expectedCaptureMode = if ($caseCode -in $derivedCases) {
|
||||
'derived_audit'
|
||||
} elseif ($caseCode -in $executeCases) {
|
||||
'execute'
|
||||
} else { 'plan_only' }
|
||||
$expectedMutation = if ($caseCode -in @(
|
||||
'purchase_unique_match_commit', 'leave_create_draft_commit',
|
||||
'leave_submit_separate_confirmation')) {
|
||||
'positive'
|
||||
} else { 'zero' }
|
||||
$expectedConfirmation = if ($expectedCaptureMode -eq 'derived_audit') {
|
||||
'inherited_required'
|
||||
} elseif ($expectedCaptureMode -eq 'execute') {
|
||||
'required'
|
||||
} else { 'prohibited' }
|
||||
$expectedSourceProof = $caseCode -in @(
|
||||
'purchase_unique_match_commit', 'purchase_idempotency_replay',
|
||||
'purchase_audit_correlated')
|
||||
if (-not (Test-ExactProperties $case @(
|
||||
'sequence', 'caseCode', 'title', 'commandName',
|
||||
'captureMode', 'expectedResultCode', 'expectedIssueCode',
|
||||
'expectedMutationPolicy', 'nativeConfirmationPolicy',
|
||||
'minimumAuditEventCount', 'sourceDocumentProofRequired',
|
||||
'primaryRole', 'supportingRoles', 'fixtureCode',
|
||||
'preconditions', 'operatorSteps', 'dbaReadOnlyChecks',
|
||||
'cleanupSteps', 'retryPolicy')) -or
|
||||
[int]$case.sequence -ne ($index + 1) -or
|
||||
[string]$case.caseCode -cne $caseCode -or
|
||||
[string]$case.commandName -cne $expectedCommand -or
|
||||
[string]$case.captureMode -cne $expectedCaptureMode -or
|
||||
([string]$case.expectedResultCode) -cnotmatch
|
||||
'^[a-z][a-z0-9_]{2,95}$' -or
|
||||
($null -ne $case.expectedIssueCode -and
|
||||
([string]$case.expectedIssueCode) -cnotmatch
|
||||
'^[a-z][a-z0-9_]{2,95}$') -or
|
||||
[string]$case.expectedMutationPolicy -cne $expectedMutation -or
|
||||
[string]$case.nativeConfirmationPolicy -cne $expectedConfirmation -or
|
||||
[int]$case.minimumAuditEventCount -ne
|
||||
$(if ($expectedCaptureMode -eq 'plan_only') { 1 } else { 2 }) -or
|
||||
[bool]$case.sourceDocumentProofRequired -ne $expectedSourceProof -or
|
||||
-not (Test-SafeCatalogText $case.title 120) -or
|
||||
([string]$case.primaryRole) -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or
|
||||
([string]$case.fixtureCode) -cnotmatch '^[a-z][a-z0-9_]{2,95}$' -or
|
||||
([string]$case.retryPolicy) -cnotin @(
|
||||
'single_success_then_relationship_cases_only',
|
||||
'new_capture_allowed_while_authorization_active',
|
||||
'new_plan_and_new_key_required',
|
||||
'dependency_key_relationship_required',
|
||||
'derived_with_dependency_only') -or
|
||||
-not (Test-SafeCatalogTextArray $case.preconditions 1 12) -or
|
||||
-not (Test-SafeCatalogTextArray $case.operatorSteps 1 12) -or
|
||||
-not (Test-SafeCatalogTextArray $case.dbaReadOnlyChecks 1 12) -or
|
||||
-not (Test-SafeCatalogTextArray $case.cleanupSteps 1 12) -or
|
||||
$lookup.ContainsKey($caseCode)) {
|
||||
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
||||
}
|
||||
if (-not ($case.supportingRoles -is [Array])) {
|
||||
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
||||
}
|
||||
$roles = @($case.supportingRoles)
|
||||
if ($roles.Count -lt 1 -or $roles.Count -gt 8 -or
|
||||
@($roles | Select-Object -Unique).Count -ne $roles.Count) {
|
||||
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
||||
}
|
||||
foreach ($role in $roles) {
|
||||
if ([string]$role -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or
|
||||
[string]$role -ceq [string]$case.primaryRole) {
|
||||
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
||||
}
|
||||
}
|
||||
$lookup[$caseCode] = $case
|
||||
}
|
||||
}
|
||||
if ($lookup.Count -ne 32) {
|
||||
Throw-CampaignError 'case_catalog_case_coverage_invalid'
|
||||
}
|
||||
return $lookup
|
||||
}
|
||||
|
||||
try {
|
||||
$caseCatalog = Open-LockedRegularFile `
|
||||
$CaseCatalogFile (256KB) 'workflow-write-uat-case-catalog.v1.json' `
|
||||
'case_catalog_file_invalid'
|
||||
$authorization = Open-LockedRegularFile `
|
||||
$UatAuthorizationFile (512KB) '' 'uat_authorization_file_invalid'
|
||||
$verifierCli = Open-LockedRegularFile `
|
||||
$VerifierCliPath (128MB) 'lserp-cli.exe' 'verifier_cli_invalid'
|
||||
$runtimeCli = Open-LockedRegularFile `
|
||||
$RuntimeCliPath (128MB) 'lserp-agent-cli.exe' 'runtime_cli_invalid'
|
||||
if ($verifierCli.Path -ieq $runtimeCli.Path) {
|
||||
Throw-CampaignError 'cli_role_path_conflict'
|
||||
}
|
||||
$caseCatalogHash = Get-LockedSha256 $caseCatalog.Stream
|
||||
if ($caseCatalogHash -cne $expectedCaseCatalogSha256) {
|
||||
Throw-CampaignError 'case_catalog_hash_mismatch'
|
||||
}
|
||||
$caseCatalogJson = Read-LockedJson $caseCatalog 'case_catalog_json_invalid'
|
||||
$catalogCaseByCode = Assert-CaseCatalog $caseCatalogJson
|
||||
$authorizationSourceHash = Get-LockedSha256 $authorization.Stream
|
||||
if ($authorizationSourceHash -cne
|
||||
$ExpectedUatAuthorizationSha256.ToLowerInvariant()) {
|
||||
Throw-CampaignError 'uat_authorization_hash_mismatch'
|
||||
}
|
||||
$verifierCliHash = Get-LockedSha256 $verifierCli.Stream
|
||||
$runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream
|
||||
if ($verifierCliHash -cne
|
||||
$ExpectedVerifierCliSha256.ToLowerInvariant()) {
|
||||
Throw-CampaignError 'verifier_cli_hash_mismatch'
|
||||
}
|
||||
if ($runtimeCliHash -cne $ExpectedRuntimeCliSha256.ToLowerInvariant()) {
|
||||
Throw-CampaignError 'runtime_cli_hash_mismatch'
|
||||
}
|
||||
$verifierSignature = Get-AuthenticodeSignature -LiteralPath $verifierCli.Path
|
||||
$verifierSigner = if ($null -eq $verifierSignature.SignerCertificate) { '' } else {
|
||||
([string]$verifierSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant()
|
||||
}
|
||||
if ($verifierSignature.Status -ne
|
||||
[Management.Automation.SignatureStatus]::Valid -or
|
||||
$verifierSigner -cne
|
||||
$ExpectedVerifierSignerThumbprint.ToUpperInvariant()) {
|
||||
Throw-CampaignError 'verifier_cli_signature_invalid'
|
||||
}
|
||||
$runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCli.Path
|
||||
$runtimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) { '' } else {
|
||||
([string]$runtimeSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant()
|
||||
}
|
||||
if ($runtimeSignature.Status -ne
|
||||
[Management.Automation.SignatureStatus]::Valid -or
|
||||
$runtimeSigner -cne
|
||||
$ExpectedRuntimeSignerThumbprint.ToUpperInvariant()) {
|
||||
Throw-CampaignError 'runtime_cli_signature_invalid'
|
||||
}
|
||||
|
||||
$runtimeIdentity = Invoke-TrustedCli $runtimeCli.Path @(
|
||||
'version', '--correlation-id',
|
||||
('campaign-runtime-' + [Guid]::NewGuid().ToString('N'))
|
||||
)
|
||||
if (-not (Test-ExactProperties $runtimeIdentity @(
|
||||
'component', 'version', 'protocolVersion', 'bridgeOnly',
|
||||
'databaseDirectAccess', 'sessionSource')) -or
|
||||
[string]$runtimeIdentity.component -cne 'lserp-agent-cli' -or
|
||||
[string]$runtimeIdentity.version -cne $ExpectedRuntimeCliVersion -or
|
||||
[string]$runtimeIdentity.protocolVersion -cne '1.0' -or
|
||||
$runtimeIdentity.bridgeOnly -ne $true -or
|
||||
$runtimeIdentity.databaseDirectAccess -ne $false -or
|
||||
[string]$runtimeIdentity.sessionSource -cne
|
||||
'current_logged_in_erp_process') {
|
||||
Throw-CampaignError 'runtime_cli_identity_invalid'
|
||||
}
|
||||
|
||||
$verified = Invoke-TrustedCli $verifierCli.Path @(
|
||||
'acceptance', 'verify-uat-authorization', '--input', $authorization.Path,
|
||||
'--correlation-id', ('campaign-auth-' + [Guid]::NewGuid().ToString('N'))
|
||||
)
|
||||
$verifiedProperties = @(
|
||||
'packageType', 'schemaVersion', 'sourceSha256', 'contentSha256',
|
||||
'authorizationId', 'authorizationIdSha256', 'customerId',
|
||||
'environmentId', 'environmentClass', 'erpScope',
|
||||
'runtimeConfigurationSha256', 'customerProfileSha256',
|
||||
'rolloutPolicySha256', 'sourceCommit', 'packageSha256',
|
||||
'erpExecutable', 'runtimeCli', 'verifierCli', 'workflows', 'issuedAtUtc',
|
||||
'expiresAtUtc', 'approvedBy', 'signatureVerified', 'uatAuthorized',
|
||||
'productionReady', 'note'
|
||||
)
|
||||
if (-not (Test-ExactProperties $verified $verifiedProperties) -or
|
||||
$verified.packageType -cne 'workflow_write_uat_authorization' -or
|
||||
$verified.schemaVersion -cne '1.2' -or
|
||||
[string]$verified.sourceSha256 -cne $authorizationSourceHash -or
|
||||
-not (Test-ExactProperties $verified.runtimeCli @(
|
||||
'fileName', 'version', 'sha256', 'signerThumbprint',
|
||||
'requiresElevation', 'bridgeOnly', 'databaseDirectAccess',
|
||||
'sessionSource')) -or
|
||||
[string]$verified.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or
|
||||
[string]$verified.runtimeCli.version -cne $ExpectedRuntimeCliVersion -or
|
||||
[string]$verified.runtimeCli.sha256 -cne $runtimeCliHash -or
|
||||
([string]$verified.runtimeCli.signerThumbprint).ToUpperInvariant() -cne
|
||||
$runtimeSigner -or
|
||||
$verified.runtimeCli.requiresElevation -ne $false -or
|
||||
$verified.runtimeCli.bridgeOnly -ne $true -or
|
||||
$verified.runtimeCli.databaseDirectAccess -ne $false -or
|
||||
[string]$verified.runtimeCli.sessionSource -cne
|
||||
'current_logged_in_erp_process' -or
|
||||
[string]$verified.verifierCli.sha256 -cne $verifierCliHash -or
|
||||
([string]$verified.verifierCli.signerThumbprint).ToUpperInvariant() -cne
|
||||
$verifierSigner -or
|
||||
$verified.verifierCli.requiresElevation -ne $true -or
|
||||
$verified.environmentClass -cne 'recoverable_uat' -or
|
||||
$verified.signatureVerified -ne $true -or
|
||||
$verified.uatAuthorized -ne $true -or
|
||||
$verified.productionReady -ne $false) {
|
||||
Throw-CampaignError 'uat_authorization_contract_mismatch'
|
||||
}
|
||||
|
||||
$authorization.Stream.Position = 0
|
||||
$reader = New-Object IO.StreamReader($authorization.Stream, $utf8, $true, 4096, $true)
|
||||
try { $authorizationText = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
||||
try { $authorizationJson = $authorizationText | ConvertFrom-Json }
|
||||
catch { Throw-CampaignError 'uat_authorization_json_invalid' }
|
||||
if (-not (Test-ExactProperties $authorizationJson @(
|
||||
'schemaVersion', 'contentSha256', 'signatureAlgorithm',
|
||||
'certificateThumbprint', 'signatureBase64', 'content')) -or
|
||||
[string]$authorizationJson.schemaVersion -cne '1.2' -or
|
||||
[string]$authorizationJson.contentSha256 -cne [string]$verified.contentSha256) {
|
||||
Throw-CampaignError 'uat_authorization_json_invalid'
|
||||
}
|
||||
$content = $authorizationJson.content
|
||||
if (-not (Test-ExactProperties $content @(
|
||||
'packageType', 'authorizationId', 'customerId', 'environmentId',
|
||||
'environmentClass', 'erpScope', 'runtimeConfigurationSha256',
|
||||
'customerProfileSha256', 'rolloutPolicySha256', 'sourceCommit',
|
||||
'packageSha256', 'erpExecutable', 'runtimeCli', 'verifierCli', 'safety',
|
||||
'workflows', 'issuedAtUtc', 'expiresAtUtc', 'approvedBy', 'note')) -or
|
||||
-not (Test-ExactProperties $content.erpScope @(
|
||||
'accountBook', 'subSystemId', 'userId', 'userName',
|
||||
'databaseScopeFingerprint')) -or
|
||||
[string]$content.erpScope.databaseScopeFingerprint -cne
|
||||
[string]$verified.erpScope.databaseScopeFingerprint -or
|
||||
-not (Test-ExactProperties $content.safety @(
|
||||
'databaseBackupVerified', 'restoreProcedureVerified',
|
||||
'nonProductionEnvironmentVerified', 'productionUseProhibited',
|
||||
'nativeConfirmationRequired', 'transactionAndAuditRequired',
|
||||
'maximumPlanAttemptsPerCase', 'maximumExecuteAttemptsPerCase')) -or
|
||||
$content.safety.databaseBackupVerified -ne $true -or
|
||||
$content.safety.restoreProcedureVerified -ne $true -or
|
||||
$content.safety.nonProductionEnvironmentVerified -ne $true -or
|
||||
$content.safety.productionUseProhibited -ne $true -or
|
||||
$content.safety.nativeConfirmationRequired -ne $true -or
|
||||
$content.safety.transactionAndAuditRequired -ne $true -or
|
||||
[int]$content.safety.maximumPlanAttemptsPerCase -ne 6 -or
|
||||
[int]$content.safety.maximumExecuteAttemptsPerCase -ne 3) {
|
||||
Throw-CampaignError 'uat_authorization_safety_invalid'
|
||||
}
|
||||
|
||||
$workflowNames = @($verified.workflows | ForEach-Object { [string]$_.workflow })
|
||||
if ($workflowNames.Count -lt 1 -or $workflowNames.Count -gt 2 -or
|
||||
@($workflowNames | Select-Object -Unique).Count -ne $workflowNames.Count -or
|
||||
@($workflowNames | Where-Object { $_ -cnotin @('purchase', 'leave') }).Count -ne 0) {
|
||||
Throw-CampaignError 'uat_authorization_workflows_invalid'
|
||||
}
|
||||
$seenTokenHashes = @{}
|
||||
$campaignWorkflows = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($workflowName in @('purchase', 'leave')) {
|
||||
if ($workflowNames -cnotcontains $workflowName) { continue }
|
||||
$expectedCases = if ($workflowName -eq 'purchase') { $purchaseCases } else { $leaveCases }
|
||||
$sourceWorkflow = @($content.workflows | Where-Object {
|
||||
[string]$_.workflow -ceq $workflowName
|
||||
})
|
||||
$verifiedWorkflow = @($verified.workflows | Where-Object {
|
||||
[string]$_.workflow -ceq $workflowName
|
||||
})
|
||||
if ($sourceWorkflow.Count -ne 1 -or $verifiedWorkflow.Count -ne 1 -or
|
||||
-not (Test-ExactProperties $sourceWorkflow[0] @(
|
||||
'workflow', 'moduleCode', 'adapterId', 'adapterVersion', 'cases')) -or
|
||||
[int]$verifiedWorkflow[0].caseCount -ne $expectedCases.Count -or
|
||||
[string]$sourceWorkflow[0].moduleCode -cne [string]$verifiedWorkflow[0].moduleCode) {
|
||||
Throw-CampaignError 'uat_authorization_workflow_contract_invalid'
|
||||
}
|
||||
$sourceCases = @($sourceWorkflow[0].cases)
|
||||
if ($sourceCases.Count -ne $expectedCases.Count) {
|
||||
Throw-CampaignError 'uat_authorization_case_coverage_invalid'
|
||||
}
|
||||
$campaignCases = New-Object System.Collections.Generic.List[object]
|
||||
for ($index = 0; $index -lt $expectedCases.Count; $index++) {
|
||||
$caseCode = $expectedCases[$index]
|
||||
$sourceCase = $sourceCases[$index]
|
||||
$commandName = Get-ExpectedCommand $caseCode
|
||||
$catalogCase = $catalogCaseByCode[$caseCode]
|
||||
$allowedCommands = @(Get-AllowedCommands $commandName)
|
||||
if (-not (Test-ExactProperties $sourceCase @(
|
||||
'caseCode', 'expectedCommandName', 'allowedCommands', 'tokenSha256')) -or
|
||||
[string]$sourceCase.caseCode -cne $caseCode -or
|
||||
[string]$sourceCase.expectedCommandName -cne $commandName -or
|
||||
[string]$catalogCase.commandName -cne $commandName -or
|
||||
-not (Test-ExactStringArray @($sourceCase.allowedCommands) $allowedCommands) -or
|
||||
([string]$sourceCase.tokenSha256) -cnotmatch '^[a-f0-9]{64}$' -or
|
||||
$seenTokenHashes.ContainsKey([string]$sourceCase.tokenSha256)) {
|
||||
Throw-CampaignError 'uat_authorization_case_contract_invalid'
|
||||
}
|
||||
$seenTokenHashes[[string]$sourceCase.tokenSha256] = $true
|
||||
$captureMode = if ($caseCode -in $derivedCases) {
|
||||
'derived_audit'
|
||||
} elseif ($caseCode -in $executeCases) {
|
||||
'execute'
|
||||
} else { 'plan_only' }
|
||||
if ([string]$catalogCase.captureMode -cne $captureMode) {
|
||||
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
||||
}
|
||||
$dependency = Get-Dependency $caseCode
|
||||
$auditOutput = if ($caseCode -eq 'purchase_unique_match_commit') {
|
||||
'purchase_audit_correlated.json'
|
||||
} elseif ($caseCode -eq 'leave_create_draft_commit') {
|
||||
'leave_audit_correlated.json'
|
||||
} else { $null }
|
||||
$operatorStage = if ($captureMode -eq 'derived_audit') {
|
||||
'derived_from_dependency'
|
||||
} elseif ($caseCode -in $postPlanStagingCases) {
|
||||
'post_plan_change_required'
|
||||
} elseif ($caseCode.EndsWith('_transaction_rollback', [StringComparison]::Ordinal)) {
|
||||
'controlled_failure_fixture_required'
|
||||
} elseif ($null -ne $dependency) {
|
||||
'relationship_fixture_required'
|
||||
} else { 'case_fixture_required' }
|
||||
$campaignCases.Add([pscustomobject][ordered]@{
|
||||
sequence = $index + 1
|
||||
caseCode = $caseCode
|
||||
commandName = $commandName
|
||||
captureMode = $captureMode
|
||||
execute = ($captureMode -eq 'execute')
|
||||
pauseAfterPlanForOperatorStaging = ($caseCode -in $postPlanStagingCases)
|
||||
operatorStage = $operatorStage
|
||||
dependencyCaseCode = $dependency
|
||||
idempotencyPolicy = Get-IdempotencyPolicy $caseCode $captureMode
|
||||
inputFile = if ($captureMode -eq 'derived_audit') {
|
||||
$null
|
||||
} else { 'private-input/' + $caseCode + '.json' }
|
||||
evidenceFile = $caseCode + '.json'
|
||||
correlatedAuditEvidenceFile = $auditOutput
|
||||
requiresDbaReadOnlyObservation = $true
|
||||
expectedBusinessMutation =
|
||||
[string]$catalogCase.expectedMutationPolicy
|
||||
})
|
||||
}
|
||||
$campaignWorkflows.Add([pscustomobject][ordered]@{
|
||||
workflow = $workflowName
|
||||
moduleCode = [string]$sourceWorkflow[0].moduleCode
|
||||
caseCount = $expectedCases.Count
|
||||
caseIndexFile = 'evidence/' + $workflowName + '-index.json'
|
||||
assembledCasesFile = 'evidence/' + $workflowName + '-cases.json'
|
||||
cases = @($campaignCases)
|
||||
})
|
||||
}
|
||||
if ($campaignWorkflows.Count -ne $workflowNames.Count) {
|
||||
Throw-CampaignError 'uat_authorization_workflows_invalid'
|
||||
}
|
||||
|
||||
$campaignDirectory = New-RestrictedCampaignDirectory $OutputRoot $CampaignId
|
||||
$privateInputDirectory = Join-Path $campaignDirectory 'private-input'
|
||||
$evidenceDirectory = Join-Path $campaignDirectory 'evidence'
|
||||
[IO.Directory]::CreateDirectory($privateInputDirectory) | Out-Null
|
||||
[IO.Directory]::CreateDirectory($evidenceDirectory) | Out-Null
|
||||
Assert-RestrictedDirectoryAcl $campaignDirectory
|
||||
|
||||
$createdAtUnixSeconds = ConvertTo-UnixSeconds ([DateTimeOffset]::UtcNow)
|
||||
$manifestContent = [pscustomobject][ordered]@{
|
||||
packageType = 'workflow_write_uat_campaign'
|
||||
campaignId = $CampaignId
|
||||
createdAtUnixSeconds = $createdAtUnixSeconds
|
||||
caseCatalogSha256 = $caseCatalogHash
|
||||
authorization = [pscustomobject][ordered]@{
|
||||
sourceSha256 = [string]$verified.sourceSha256
|
||||
contentSha256 = [string]$verified.contentSha256
|
||||
authorizationIdSha256 = [string]$verified.authorizationIdSha256
|
||||
customerId = [string]$verified.customerId
|
||||
environmentId = [string]$verified.environmentId
|
||||
environmentClass = 'recoverable_uat'
|
||||
issuedAtUnixSeconds = ConvertTo-UnixSeconds $verified.issuedAtUtc
|
||||
expiresAtUnixSeconds = ConvertTo-UnixSeconds $verified.expiresAtUtc
|
||||
verifierCliSha256 = $verifierCliHash
|
||||
verifierCliSignerThumbprint = $verifierSigner
|
||||
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
||||
runtimeCliSha256 = $runtimeCliHash
|
||||
runtimeCliSignerThumbprint = $runtimeSigner
|
||||
userIdSha256 = [string]$verified.erpScope.userIdSha256
|
||||
userNameSha256 = [string]$verified.erpScope.userNameSha256
|
||||
databaseScopeFingerprint =
|
||||
[string]$verified.erpScope.databaseScopeFingerprint
|
||||
}
|
||||
safety = [pscustomobject][ordered]@{
|
||||
productionUseProhibited = $true
|
||||
automaticDatabaseWrites = $false
|
||||
oneCaseAtATime = $true
|
||||
operatorStagingRequired = $true
|
||||
authorizationReverificationRequiredBeforeResume = $true
|
||||
tokenMaterialIncluded = $false
|
||||
idempotencyMaterialIncluded = $false
|
||||
privateBusinessInputIncluded = $false
|
||||
resumeStateDerivedOnlyFromEvidence = $true
|
||||
}
|
||||
directories = [pscustomobject][ordered]@{
|
||||
privateInput = 'private-input'
|
||||
evidence = 'evidence'
|
||||
}
|
||||
workflows = @($campaignWorkflows)
|
||||
productionReady = $false
|
||||
}
|
||||
$manifestContentJson = $manifestContent | ConvertTo-Json -Depth 14 -Compress
|
||||
$manifestContentSha256 = Get-Sha256Hex $utf8.GetBytes($manifestContentJson)
|
||||
$manifest = [pscustomobject][ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
contentSha256 = $manifestContentSha256
|
||||
content = $manifestContent
|
||||
}
|
||||
Write-NewJson (Join-Path $campaignDirectory 'campaign.json') $manifest 16
|
||||
foreach ($workflow in $campaignWorkflows) {
|
||||
$indexDocument = [pscustomobject][ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
workflow = [string]$workflow.workflow
|
||||
caseFiles = @($workflow.cases | ForEach-Object { [string]$_.evidenceFile })
|
||||
}
|
||||
Write-NewJson `
|
||||
(Join-Path $evidenceDirectory ([string]$workflow.workflow + '-index.json')) `
|
||||
$indexDocument 5
|
||||
}
|
||||
$campaignCaseCount = 0
|
||||
foreach ($workflow in $campaignWorkflows) {
|
||||
$campaignCaseCount += @($workflow.cases).Count
|
||||
}
|
||||
|
||||
[pscustomobject][ordered]@{
|
||||
campaignFile = Join-Path $campaignDirectory 'campaign.json'
|
||||
campaignId = $CampaignId
|
||||
campaignContentSha256 = $manifestContentSha256
|
||||
caseCatalogSha256 = $caseCatalogHash
|
||||
workflowCount = $campaignWorkflows.Count
|
||||
caseCount = $campaignCaseCount
|
||||
authorizationSignatureVerified = $true
|
||||
verifierCliSha256 = $verifierCliHash
|
||||
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
||||
runtimeCliSha256 = $runtimeCliHash
|
||||
runtimeCliSignerThumbprint = $runtimeSigner
|
||||
automaticDatabaseWrites = $false
|
||||
tokenMaterialIncluded = $false
|
||||
readyForOneCaseAtATimeCapture = $true
|
||||
productionReady = $false
|
||||
} | ConvertTo-Json -Depth 5
|
||||
}
|
||||
catch {
|
||||
if ($null -ne $campaignDirectory -and
|
||||
[IO.Directory]::Exists($campaignDirectory)) {
|
||||
try { [IO.Directory]::Delete($campaignDirectory, $true) } catch { }
|
||||
}
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
foreach ($lock in @($locks)) {
|
||||
if ($null -ne $lock) { $lock.Dispose() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$HostDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||
[string]$CertificateThumbprint,
|
||||
[ValidateSet('CurrentUser', 'LocalMachine')]
|
||||
[string]$CertificateStoreLocation = 'CurrentUser',
|
||||
[Parameter(Mandatory = $true)][string]$TimestampUrl,
|
||||
[string]$SignToolPath = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Test-RegularFile([string]$Path, [long]$MaximumBytes) {
|
||||
if (-not [IO.File]::Exists($Path)) { return $false }
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
return $item.Length -gt 0 -and $item.Length -le $MaximumBytes -and
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)
|
||||
}
|
||||
|
||||
function Find-SignTool([string]$ExplicitPath) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
|
||||
$resolved = [IO.Path]::GetFullPath($ExplicitPath)
|
||||
if (Test-RegularFile $resolved 128MB) { return $resolved }
|
||||
throw 'signtool_invalid'
|
||||
}
|
||||
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if ($null -ne $command -and (Test-RegularFile $command.Source 128MB)) {
|
||||
return $command.Source
|
||||
}
|
||||
$programFilesX86 = [Environment]::GetFolderPath(
|
||||
[Environment+SpecialFolder]::ProgramFilesX86)
|
||||
foreach ($kitsVersion in @('10', '8.1')) {
|
||||
$binRoot = Join-Path $programFilesX86 ("Windows Kits\{0}\bin" -f $kitsVersion)
|
||||
if (-not [IO.Directory]::Exists($binRoot)) { continue }
|
||||
$candidates = @(Get-ChildItem -LiteralPath $binRoot -Directory -Force |
|
||||
Sort-Object Name -Descending | ForEach-Object {
|
||||
Join-Path $_.FullName 'x86\signtool.exe'
|
||||
Join-Path $_.FullName 'x64\signtool.exe'
|
||||
})
|
||||
$candidates += @(Join-Path $binRoot 'x86\signtool.exe')
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-RegularFile $candidate 128MB) { return $candidate }
|
||||
}
|
||||
}
|
||||
throw 'signtool_not_found'
|
||||
}
|
||||
|
||||
if ($env:OS -ne 'Windows_NT') { throw 'windows_required' }
|
||||
$timestampUri = $null
|
||||
if (-not [Uri]::TryCreate($TimestampUrl, [UriKind]::Absolute, [ref]$timestampUri) -or
|
||||
$timestampUri.Scheme -ne 'https' -or $timestampUri.UserInfo -or
|
||||
$timestampUri.Fragment) {
|
||||
throw 'https_timestamp_url_required'
|
||||
}
|
||||
|
||||
$hostRoot = [IO.Path]::GetFullPath($HostDirectory).TrimEnd(
|
||||
[char[]]@('\', '/')) + [IO.Path]::DirectorySeparatorChar
|
||||
if (-not [IO.Directory]::Exists($hostRoot)) { throw 'host_directory_missing' }
|
||||
$rootItem = Get-Item -LiteralPath $hostRoot -Force
|
||||
if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'host_directory_reparse_forbidden'
|
||||
}
|
||||
|
||||
$criticalRelativePaths = @(
|
||||
'Lskj.AgentPet.Host.exe',
|
||||
'Lskj.AgentPet.Host.dll',
|
||||
'Lskj.AgentPet.Host.Core.dll',
|
||||
'lserp-agent-cli.exe'
|
||||
)
|
||||
$criticalPaths = @()
|
||||
foreach ($relative in $criticalRelativePaths) {
|
||||
$full = [IO.Path]::GetFullPath((Join-Path $hostRoot $relative))
|
||||
if (-not $full.StartsWith($hostRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not (Test-RegularFile $full 512MB)) {
|
||||
throw 'host_critical_binary_missing'
|
||||
}
|
||||
$criticalPaths += $full
|
||||
}
|
||||
|
||||
$normalizedThumbprint = $CertificateThumbprint.ToUpperInvariant()
|
||||
$certificatePath = "Cert:\{0}\My\{1}" -f `
|
||||
$CertificateStoreLocation, $normalizedThumbprint
|
||||
if (-not (Test-Path -LiteralPath $certificatePath)) {
|
||||
throw 'host_signing_certificate_missing'
|
||||
}
|
||||
$certificate = Get-Item -LiteralPath $certificatePath
|
||||
$codeSigningOid = '1.3.6.1.5.5.7.3.3'
|
||||
$hasCodeSigningEku = @($certificate.EnhancedKeyUsageList | Where-Object {
|
||||
$_.ObjectId.Value -eq $codeSigningOid
|
||||
}).Count -gt 0
|
||||
$now = [DateTime]::UtcNow
|
||||
if (-not $certificate.HasPrivateKey -or -not $hasCodeSigningEku -or
|
||||
$certificate.NotBefore.ToUniversalTime() -gt $now -or
|
||||
$certificate.NotAfter.ToUniversalTime() -le $now) {
|
||||
throw 'host_signing_certificate_invalid'
|
||||
}
|
||||
|
||||
$signTool = Find-SignTool $SignToolPath
|
||||
$signed = @()
|
||||
foreach ($criticalPath in $criticalPaths) {
|
||||
$arguments = @(
|
||||
'sign', '/nologo', '/sha1', $certificate.Thumbprint,
|
||||
'/s', 'My', '/fd', 'SHA256', '/tr', $timestampUri.AbsoluteUri,
|
||||
'/td', 'SHA256', $criticalPath
|
||||
)
|
||||
if ($CertificateStoreLocation -eq 'LocalMachine') {
|
||||
$arguments = @(
|
||||
'sign', '/nologo', '/sm', '/sha1', $certificate.Thumbprint,
|
||||
'/s', 'My', '/fd', 'SHA256', '/tr', $timestampUri.AbsoluteUri,
|
||||
'/td', 'SHA256', $criticalPath
|
||||
)
|
||||
}
|
||||
& $signTool @arguments | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'host_authenticode_signing_failed' }
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $criticalPath
|
||||
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or
|
||||
$null -eq $signature.SignerCertificate -or
|
||||
$signature.SignerCertificate.Thumbprint.ToUpperInvariant() -ne
|
||||
$normalizedThumbprint -or
|
||||
$null -eq $signature.TimeStamperCertificate) {
|
||||
throw 'host_authenticode_verification_failed'
|
||||
}
|
||||
$signed += [ordered]@{
|
||||
file = [IO.Path]::GetFileName($criticalPath)
|
||||
sha256 = (Get-FileHash -LiteralPath $criticalPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
schemaVersion = '1.0'
|
||||
certificateThumbprint = $normalizedThumbprint
|
||||
timestampUrl = $timestampUri.AbsoluteUri
|
||||
signed = $signed
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
@@ -0,0 +1,375 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_command_idempotency', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_command_idempotency
|
||||
(
|
||||
id BIGINT IDENTITY(1, 1) NOT NULL,
|
||||
account_book NVARCHAR(64) NOT NULL,
|
||||
subsystem_id NVARCHAR(32) NOT NULL,
|
||||
user_id NVARCHAR(64) NOT NULL,
|
||||
command_name VARCHAR(128) NOT NULL,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
input_fingerprint CHAR(64) NOT NULL,
|
||||
status TINYINT NOT NULL,
|
||||
result_code VARCHAR(128) NULL,
|
||||
record_id NVARCHAR(128) NULL,
|
||||
transaction_evidence_id VARCHAR(128) NULL,
|
||||
business_audit_id VARCHAR(128) NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_command_idempotency_created DEFAULT SYSUTCDATETIME(),
|
||||
completed_at_utc DATETIME2(3) NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_command_idempotency PRIMARY KEY CLUSTERED (id),
|
||||
CONSTRAINT UQ_p_agent_command_idempotency_scope UNIQUE
|
||||
(account_book, subsystem_id, user_id, command_name, idempotency_key),
|
||||
CONSTRAINT CK_p_agent_command_idempotency_status CHECK (status IN (0, 1, 2)),
|
||||
CONSTRAINT CK_p_agent_command_idempotency_fingerprint_length
|
||||
CHECK (LEN(input_fingerprint) = 64)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_p_agent_command_idempotency_created
|
||||
ON dbo.p_agent_command_idempotency(created_at_utc)
|
||||
INCLUDE(status, completed_at_utc);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_audit', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_business_audit
|
||||
(
|
||||
business_audit_id VARCHAR(128) NOT NULL,
|
||||
transaction_evidence_id VARCHAR(128) NOT NULL,
|
||||
correlation_id VARCHAR(128) NOT NULL,
|
||||
idempotency_id BIGINT NOT NULL,
|
||||
account_book NVARCHAR(64) NOT NULL,
|
||||
subsystem_id NVARCHAR(32) NOT NULL,
|
||||
user_id NVARCHAR(64) NOT NULL,
|
||||
command_name VARCHAR(128) NOT NULL,
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
action_name VARCHAR(64) NOT NULL,
|
||||
record_id NVARCHAR(128) NOT NULL,
|
||||
input_fingerprint CHAR(64) NOT NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_business_audit_created DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT PK_p_agent_business_audit
|
||||
PRIMARY KEY CLUSTERED (business_audit_id),
|
||||
CONSTRAINT UQ_p_agent_business_audit_transaction
|
||||
UNIQUE (transaction_evidence_id),
|
||||
CONSTRAINT FK_p_agent_business_audit_idempotency
|
||||
FOREIGN KEY (idempotency_id)
|
||||
REFERENCES dbo.p_agent_command_idempotency(id),
|
||||
CONSTRAINT CK_p_agent_business_audit_fingerprint_length
|
||||
CHECK (LEN(input_fingerprint) = 64)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_p_agent_business_audit_correlation
|
||||
ON dbo.p_agent_business_audit(correlation_id, created_at_utc)
|
||||
INCLUDE(module_code, action_name, record_id);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_source_document', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_business_source_document
|
||||
(
|
||||
id BIGINT IDENTITY(1, 1) NOT NULL,
|
||||
business_audit_id VARCHAR(128) NOT NULL,
|
||||
document_kind VARCHAR(8) NOT NULL,
|
||||
sanitized_filename NVARCHAR(128) NOT NULL,
|
||||
source_sha256 CHAR(64) NOT NULL,
|
||||
extraction_sha256 CHAR(64) NOT NULL,
|
||||
preprocess_contract VARCHAR(64) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_business_source_created DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT PK_p_agent_business_source_document PRIMARY KEY CLUSTERED (id),
|
||||
CONSTRAINT UQ_p_agent_business_source_document
|
||||
UNIQUE (business_audit_id, source_sha256),
|
||||
CONSTRAINT FK_p_agent_business_source_audit
|
||||
FOREIGN KEY (business_audit_id)
|
||||
REFERENCES dbo.p_agent_business_audit(business_audit_id),
|
||||
CONSTRAINT CK_p_agent_business_source_kind
|
||||
CHECK (document_kind IN ('image', 'file')),
|
||||
CONSTRAINT CK_p_agent_business_source_hash
|
||||
CHECK (LEN(source_sha256) = 64),
|
||||
CONSTRAINT CK_p_agent_business_extraction_hash
|
||||
CHECK (LEN(extraction_sha256) = 64
|
||||
AND extraction_sha256 COLLATE Latin1_General_100_BIN2
|
||||
NOT LIKE '%[^0-9a-f]%'),
|
||||
CONSTRAINT CK_p_agent_business_preprocess_contract
|
||||
CHECK
|
||||
(
|
||||
(document_kind COLLATE Latin1_General_100_BIN2 = 'image'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'minimax_vlm_0.0.4'
|
||||
AND
|
||||
(
|
||||
LOWER(RIGHT(sanitized_filename, 4)) IN (N'.png', N'.jpg')
|
||||
OR LOWER(RIGHT(sanitized_filename, 5)) IN (N'.jpeg', N'.webp')
|
||||
))
|
||||
OR
|
||||
(document_kind COLLATE Latin1_General_100_BIN2 = 'file'
|
||||
AND
|
||||
(
|
||||
(LOWER(RIGHT(sanitized_filename, 4)) = N'.pdf'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'pdfium_minimax_pages_v1')
|
||||
OR
|
||||
(LOWER(RIGHT(sanitized_filename, 4)) = N'.csv'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'document_sandbox_csv_v1')
|
||||
OR
|
||||
(LOWER(RIGHT(sanitized_filename, 5)) = N'.xlsx'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'document_sandbox_xlsx_v1')
|
||||
))
|
||||
),
|
||||
CONSTRAINT CK_p_agent_business_source_size
|
||||
CHECK (size_bytes BETWEEN 1 AND 12582912)
|
||||
);
|
||||
END;
|
||||
|
||||
/*
|
||||
Upgrade path for an existing evidence table. Historical rows cannot reconstruct the
|
||||
exact Agent extraction or preprocessing implementation, so added columns remain
|
||||
nullable for those rows. All purchase v1.4 writes require and insert both values.
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_source_document', N'U') IS NOT NULL
|
||||
AND COL_LENGTH(
|
||||
N'dbo.p_agent_business_source_document', N'extraction_sha256') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.p_agent_business_source_document
|
||||
ADD extraction_sha256 CHAR(64) NULL;
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_source_document', N'U') IS NOT NULL
|
||||
AND COL_LENGTH(
|
||||
N'dbo.p_agent_business_source_document', N'preprocess_contract') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.p_agent_business_source_document
|
||||
ADD preprocess_contract VARCHAR(64) NULL;
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_source_document', N'U') IS NOT NULL
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM sys.check_constraints
|
||||
WHERE parent_object_id = OBJECT_ID(
|
||||
N'dbo.p_agent_business_source_document')
|
||||
AND name = N'CK_p_agent_business_extraction_hash'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.p_agent_business_source_document WITH CHECK
|
||||
ADD CONSTRAINT CK_p_agent_business_extraction_hash
|
||||
CHECK
|
||||
(
|
||||
extraction_sha256 IS NULL
|
||||
OR
|
||||
(
|
||||
LEN(extraction_sha256) = 64
|
||||
AND extraction_sha256 COLLATE Latin1_General_100_BIN2
|
||||
NOT LIKE '%[^0-9a-f]%'
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_business_source_document', N'U') IS NOT NULL
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM sys.check_constraints
|
||||
WHERE parent_object_id = OBJECT_ID(
|
||||
N'dbo.p_agent_business_source_document')
|
||||
AND name = N'CK_p_agent_business_preprocess_contract'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.p_agent_business_source_document WITH CHECK
|
||||
ADD CONSTRAINT CK_p_agent_business_preprocess_contract
|
||||
CHECK
|
||||
(
|
||||
preprocess_contract IS NULL
|
||||
OR
|
||||
(
|
||||
(document_kind COLLATE Latin1_General_100_BIN2 = 'image'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'minimax_vlm_0.0.4'
|
||||
AND
|
||||
(
|
||||
LOWER(RIGHT(sanitized_filename, 4)) IN (N'.png', N'.jpg')
|
||||
OR LOWER(RIGHT(sanitized_filename, 5)) IN (N'.jpeg', N'.webp')
|
||||
))
|
||||
OR
|
||||
(document_kind COLLATE Latin1_General_100_BIN2 = 'file'
|
||||
AND
|
||||
(
|
||||
(LOWER(RIGHT(sanitized_filename, 4)) = N'.pdf'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'pdfium_minimax_pages_v1')
|
||||
OR
|
||||
(LOWER(RIGHT(sanitized_filename, 4)) = N'.csv'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'document_sandbox_csv_v1')
|
||||
OR
|
||||
(LOWER(RIGHT(sanitized_filename, 5)) = N'.xlsx'
|
||||
AND preprocess_contract COLLATE Latin1_General_100_BIN2 =
|
||||
'document_sandbox_xlsx_v1')
|
||||
))
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
/*
|
||||
The table is intentionally created empty. A customer DBA may add a row only after
|
||||
finance has approved the source P_CurrencyType to target P_BaseMixInfoTab mapping.
|
||||
The customer-specific write wrapper additionally requires a signed evidence hash;
|
||||
shipping this schema never approves or guesses a currency mapping.
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_agent_purchase_currency_crosswalk', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_purchase_currency_crosswalk
|
||||
(
|
||||
source_currency_id INT NOT NULL,
|
||||
target_currency_id INT NOT NULL,
|
||||
is_active BIT NOT NULL,
|
||||
approval_evidence_sha256 CHAR(64) NOT NULL,
|
||||
approved_by NVARCHAR(64) NOT NULL,
|
||||
approved_at_utc DATETIME2(3) NOT NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_purchase_currency_created DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT PK_p_agent_purchase_currency_crosswalk
|
||||
PRIMARY KEY CLUSTERED (source_currency_id, target_currency_id),
|
||||
CONSTRAINT CK_p_agent_purchase_currency_source
|
||||
CHECK (source_currency_id > 0),
|
||||
CONSTRAINT CK_p_agent_purchase_currency_target
|
||||
CHECK (target_currency_id > 0),
|
||||
CONSTRAINT CK_p_agent_purchase_currency_hash
|
||||
CHECK (LEN(approval_evidence_sha256) = 64
|
||||
AND approval_evidence_sha256 COLLATE Latin1_General_100_BIN2
|
||||
NOT LIKE '%[^0-9a-f]%'),
|
||||
CONSTRAINT CK_p_agent_purchase_currency_approver
|
||||
CHECK (LEN(LTRIM(RTRIM(approved_by))) BETWEEN 1 AND 64)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX UX_p_agent_purchase_currency_active
|
||||
ON dbo.p_agent_purchase_currency_crosswalk(source_currency_id)
|
||||
WHERE is_active = 1;
|
||||
END;
|
||||
|
||||
/*
|
||||
The table is intentionally created empty. It grants no access by itself. A customer
|
||||
DBA may add an exact account/subsystem/user + organization/department/purchaser
|
||||
tuple only after the procurement owner has approved that row scope and supplied a
|
||||
signed evidence hash. Wildcards and NULL scope components are not supported.
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_agent_purchase_row_scope', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_purchase_row_scope
|
||||
(
|
||||
id BIGINT IDENTITY(1, 1) NOT NULL,
|
||||
account_book NVARCHAR(64) NOT NULL,
|
||||
subsystem_id NVARCHAR(32) NOT NULL,
|
||||
erp_user_id NVARCHAR(64) NOT NULL,
|
||||
group_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
purchase_user_id INT NOT NULL,
|
||||
is_active BIT NOT NULL,
|
||||
approval_evidence_sha256 CHAR(64) NOT NULL,
|
||||
approved_by NVARCHAR(64) NOT NULL,
|
||||
approved_at_utc DATETIME2(3) NOT NULL,
|
||||
valid_from_utc DATETIME2(3) NOT NULL,
|
||||
valid_to_utc DATETIME2(3) NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_purchase_scope_created DEFAULT SYSUTCDATETIME(),
|
||||
CONSTRAINT PK_p_agent_purchase_row_scope PRIMARY KEY CLUSTERED (id),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_group CHECK (group_id > 0),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_department CHECK (department_id > 0),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_user CHECK (purchase_user_id > 0),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_account
|
||||
CHECK (LEN(LTRIM(RTRIM(account_book))) BETWEEN 1 AND 64),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_subsystem
|
||||
CHECK (LEN(LTRIM(RTRIM(subsystem_id))) BETWEEN 1 AND 32),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_erp_user
|
||||
CHECK (LEN(LTRIM(RTRIM(erp_user_id))) BETWEEN 1 AND 64),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_hash
|
||||
CHECK (LEN(approval_evidence_sha256) = 64
|
||||
AND approval_evidence_sha256 COLLATE Latin1_General_100_BIN2
|
||||
NOT LIKE '%[^0-9a-f]%'),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_approver
|
||||
CHECK (LEN(LTRIM(RTRIM(approved_by))) BETWEEN 1 AND 64),
|
||||
CONSTRAINT CK_p_agent_purchase_scope_window
|
||||
CHECK (valid_to_utc IS NULL OR valid_to_utc > valid_from_utc)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX UX_p_agent_purchase_row_scope_active
|
||||
ON dbo.p_agent_purchase_row_scope
|
||||
(
|
||||
account_book, subsystem_id, erp_user_id,
|
||||
group_id, department_id, purchase_user_id
|
||||
)
|
||||
WHERE is_active = 1;
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_integration_outbox', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_integration_outbox
|
||||
(
|
||||
id BIGINT IDENTITY(1, 1) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
business_audit_id VARCHAR(128) NOT NULL,
|
||||
correlation_id VARCHAR(128) NOT NULL,
|
||||
account_book NVARCHAR(64) NOT NULL,
|
||||
subsystem_id NVARCHAR(32) NOT NULL,
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
action_name VARCHAR(64) NOT NULL,
|
||||
record_id NVARCHAR(128) NOT NULL,
|
||||
status TINYINT NOT NULL,
|
||||
attempt_count INT NOT NULL
|
||||
CONSTRAINT DF_p_agent_integration_outbox_attempt DEFAULT 0,
|
||||
available_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_integration_outbox_available DEFAULT SYSUTCDATETIME(),
|
||||
locked_at_utc DATETIME2(3) NULL,
|
||||
completed_at_utc DATETIME2(3) NULL,
|
||||
last_error_code VARCHAR(128) NULL,
|
||||
created_at_utc DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_p_agent_integration_outbox_created DEFAULT SYSUTCDATETIME(),
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_integration_outbox PRIMARY KEY CLUSTERED (id),
|
||||
CONSTRAINT UQ_p_agent_integration_outbox_event UNIQUE (event_id),
|
||||
CONSTRAINT FK_p_agent_integration_outbox_audit
|
||||
FOREIGN KEY (business_audit_id)
|
||||
REFERENCES dbo.p_agent_business_audit(business_audit_id),
|
||||
CONSTRAINT CK_p_agent_integration_outbox_status
|
||||
CHECK (status IN (0, 1, 2, 3)),
|
||||
CONSTRAINT CK_p_agent_integration_outbox_attempt
|
||||
CHECK (attempt_count >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_p_agent_integration_outbox_dispatch
|
||||
ON dbo.p_agent_integration_outbox(status, available_at_utc, id)
|
||||
INCLUDE(attempt_count, module_code, action_name, record_id);
|
||||
END;
|
||||
|
||||
/*
|
||||
status: 0=in_progress, 1=completed, 2=terminal_failure
|
||||
|
||||
客户采购/请假适配器必须在“同一个数据库事务”中完成:
|
||||
1. 使用上面的唯一作用域和 UPDLOCK,HOLDLOCK 查询幂等记录;
|
||||
2. 已存在但 input_fingerprint 不同:返回 idempotency_key_conflict;
|
||||
3. status=1:返回原 record_id、transaction_evidence_id、business_audit_id;
|
||||
4. 不存在:插入 status=0;
|
||||
5. 锁定并复核业务来源、调用原 ERP 保存链、写业务审计/Outbox;
|
||||
6. 更新 status=1 及三个证据字段;
|
||||
7. 返回完整证据,由调用方提交 Serializable 外层事务。过程本身不得 COMMIT 或
|
||||
ROLLBACK;任何异常由调用方回滚整个事务。
|
||||
|
||||
不得先提交幂等记录再另开事务保存业务数据,也不得把模型原文、发票全文、
|
||||
ERP 密码、参数值或原始 SQL 写入此表。
|
||||
|
||||
p_agent_business_audit 只保存关联、作用域、指纹和业务记录标识,不保存请假原因、
|
||||
发票全文、附件内容或 SQL。p_agent_business_source_document 只保存受信任本地 Tool
|
||||
注入且已进入输入指纹的附件类型、净化文件名、大小、源/提取 SHA-256 和精确的
|
||||
预处理实现契约,不保存附件内容或本地路径。p_agent_integration_outbox 也只保存
|
||||
重新读取业务记录所需的稳定标识;
|
||||
异步推送进程必须在 ERP 权限上下文中按 record_id 重新读取,并在事务提交后投递,
|
||||
禁止把模型原文或敏感业务载荷直接落入 Outbox。
|
||||
*/
|
||||
@@ -0,0 +1,335 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_workflow_adapter_evidence', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_workflow_adapter_evidence
|
||||
(
|
||||
workflow VARCHAR(32) NOT NULL,
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
adapter_id VARCHAR(128) NOT NULL,
|
||||
adapter_version VARCHAR(64) NOT NULL,
|
||||
evidence_id VARCHAR(128) NOT NULL,
|
||||
evidence_sha256 CHAR(64) NOT NULL,
|
||||
customer_configuration_validated BIT NOT NULL,
|
||||
parameterized_read_queries_verified BIT NOT NULL,
|
||||
transactional_write_verified BIT NOT NULL,
|
||||
persistent_idempotency_verified BIT NOT NULL,
|
||||
permission_recheck_verified BIT NOT NULL,
|
||||
windows_integration_verified BIT NOT NULL,
|
||||
validated_by NVARCHAR(128) NOT NULL,
|
||||
validated_at_utc DATETIME2(3) NOT NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_workflow_adapter_evidence
|
||||
PRIMARY KEY CLUSTERED(workflow, module_code),
|
||||
CONSTRAINT CK_p_agent_workflow_adapter_evidence_workflow
|
||||
CHECK (workflow IN ('purchase', 'leave')),
|
||||
CONSTRAINT CK_p_agent_workflow_adapter_evidence_hash
|
||||
CHECK (LEN(evidence_sha256) = 64)
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_readiness', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_readiness
|
||||
@workflow VARCHAR(32),
|
||||
@module_code NVARCHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT
|
||||
adapter_id,
|
||||
adapter_version,
|
||||
evidence_id,
|
||||
customer_configuration_validated,
|
||||
parameterized_read_queries_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
windows_integration_verified
|
||||
FROM dbo.p_agent_workflow_adapter_evidence
|
||||
WHERE workflow = @workflow
|
||||
AND module_code = @module_code;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
V2 证据按账套与子系统隔离,并把签名验收清单的 contentSha256 返回客户端。
|
||||
旧表/旧过程保留只用于审计迁移;当前客户端通过 006 中的 V3 readiness
|
||||
读取这张 V2 证据表,并额外校验已部署过程签名与修改时间,禁止自动复制旧布尔值。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_agent_workflow_adapter_evidence_v2', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_workflow_adapter_evidence_v2
|
||||
(
|
||||
workflow VARCHAR(32) NOT NULL,
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
account_book NVARCHAR(128) NOT NULL,
|
||||
subsystem_id NVARCHAR(128) NOT NULL,
|
||||
adapter_id VARCHAR(128) NOT NULL,
|
||||
adapter_version VARCHAR(64) NOT NULL,
|
||||
evidence_id VARCHAR(128) NOT NULL,
|
||||
evidence_sha256 CHAR(64) NOT NULL,
|
||||
customer_configuration_validated BIT NOT NULL,
|
||||
parameterized_read_queries_verified BIT NOT NULL,
|
||||
transactional_write_verified BIT NOT NULL,
|
||||
persistent_idempotency_verified BIT NOT NULL,
|
||||
permission_recheck_verified BIT NOT NULL,
|
||||
windows_integration_verified BIT NOT NULL,
|
||||
validated_by NVARCHAR(128) NOT NULL,
|
||||
validated_at_utc DATETIME2(3) NOT NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_workflow_adapter_evidence_v2
|
||||
PRIMARY KEY CLUSTERED(workflow, module_code, account_book, subsystem_id),
|
||||
CONSTRAINT CK_p_agent_workflow_adapter_evidence_v2_workflow
|
||||
CHECK (workflow IN ('purchase', 'leave')),
|
||||
CONSTRAINT CK_p_agent_workflow_adapter_evidence_v2_hash
|
||||
CHECK (LEN(evidence_sha256) = 64
|
||||
AND evidence_sha256 NOT LIKE '%[^0-9a-f]%')
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_readiness_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_readiness_v2
|
||||
@workflow VARCHAR(32),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT
|
||||
adapter_id,
|
||||
adapter_version,
|
||||
evidence_id,
|
||||
evidence_sha256,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
customer_configuration_validated,
|
||||
parameterized_read_queries_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
windows_integration_verified,
|
||||
validated_by,
|
||||
validated_at_utc
|
||||
FROM dbo.p_agent_workflow_adapter_evidence_v2
|
||||
WHERE workflow = @workflow
|
||||
AND module_code = @module_code
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
客户适配层必须实现下面两个固定过程。部署脚本只在过程不存在时创建拒绝服务的
|
||||
占位过程,绝不会猜测或直接写客户业务表。
|
||||
|
||||
read 按 action 返回:
|
||||
- purchase.resolve_supplier: 零到多行 supplier_code, supplier_name,
|
||||
supplier_tax_id。payload 为 reference, taxId;只允许按客户确认的精确编码、
|
||||
税号、全称或人工维护别名匹配,禁止模型生成 SQL 或自行决定编码。
|
||||
- purchase.resolve_currency: 零到多行 currency_code, currency_name。payload 为
|
||||
reference;必须按精确编码、名称或人工维护别名匹配。
|
||||
- purchase.resolve_material: 零到多行 material_code, material_name,
|
||||
specification, unit。payload 为 lineId, reference, specification, unit,
|
||||
supplierCode;只能查询当前账套允许采购且未停用的物料。
|
||||
- purchase.invoice_exists: exists(bit)
|
||||
- purchase.open_sources: source_order_id, source_order_number, source_line_id,
|
||||
supplier_code, currency_code, material_code, unit, remaining_quantity,
|
||||
unit_price, tax_rate, exchange_rate, closed(bit)。source_order_id 必须是稳定的
|
||||
采购单系统标识,source_line_id 必须是采购来源明细标识;单位和汇率均为必填。
|
||||
过程必须扣除未删除、未取消、未作废的既有单据占用,并在结果超过 10000 行时
|
||||
让调用端失败关闭,禁止静默截断后匹配。
|
||||
- leave.context: current_employee_id, can_apply_for_others(bit), now_local(datetime)
|
||||
- leave.resolve_type: 零到多行 leave_type_code, leave_type_name。payload 为
|
||||
query;只允许在当前用户可用且已启用的假别及人工维护别名中匹配,禁止让模型
|
||||
猜内部编码。结果不唯一时全部返回,由命令层停止并要求用户选择。
|
||||
- leave.resolve_flow_type: 零到多行 flow_type_code, flow_type_name。payload 为
|
||||
employeeId, calculatedHours, query;必须只返回当前模块已配置且未停用的流转
|
||||
类别。flow_type_code 必须是流程类别配置行 id,且步骤表 billType 必须存在对应
|
||||
id;不得返回名称可能重复的业务 billType 字段。客户没有经过书面验收的职级/
|
||||
工时映射时,query 为空必须返回全部候选,
|
||||
由命令层追问用户,禁止按名称、职级或天数猜审批路线。
|
||||
- leave.resolve_calendar_range: 恰好一行 available(bit), reason_code,
|
||||
start_local(datetime), end_local(datetime), hours(decimal), time_zone_id。
|
||||
payload 为 employeeId, localDate(yyyy-MM-dd), dayPart(morning/afternoon/full_day);
|
||||
必须按该员工工作日历、班次、时区和节假日计算,禁止写死 9:00-18:00。
|
||||
available=0 时 start/end/hours/time_zone_id 可为空,但 reason_code 必须为稳定代码。
|
||||
- leave.type_enabled: enabled(bit)
|
||||
- leave.flow_type_enabled: enabled(bit)
|
||||
- leave.calculate_hours: hours(decimal)
|
||||
- leave.has_conflict: has_conflict(bit)
|
||||
- leave.can_submit: can_submit(bit), reason(nvarchar)
|
||||
|
||||
write 必须在调用方已经开启的 Serializable 事务内完成原 ERP 保存链、来源锁定、
|
||||
权限复核、p_agent_command_idempotency 与业务审计,并恰好返回一行:
|
||||
success, code, message, record_id, needs_ui, idempotency_replayed,
|
||||
applied_idempotency_key, applied_input_fingerprint, transaction_evidence_id,
|
||||
business_audit_id。过程不得 COMMIT/ROLLBACK 外层事务。
|
||||
所有写过程还必须接收 correlation_id,并把它与 transaction_evidence_id、
|
||||
business_audit_id 和幂等记录绑定,禁止记录模型原文或原始 SQL。
|
||||
purchase.create_document 的 payload.draft 必须包含 totalWithoutTax、taxAmount、
|
||||
totalWithTax,且每行包含 taxAmount。客户过程必须按配置的含税/不含税口径和币种
|
||||
精度重新计算头行价税平衡;不得只信任 Agent 传入的汇总值。若 payload.draft
|
||||
包含 sourceDocuments,过程必须把每份 kind、filename、sizeBytes、sha256、
|
||||
extractionSha256 与业务审计记录关联保存。extractionSha256 是受信任本地 Tool
|
||||
对实际注入 Agent 的精确 UTF-8 预处理包计算的摘要;不得用模型或数据库重新
|
||||
生成的值替换这些已由解析凭证和 inputFingerprint 绑定的来源凭据。
|
||||
*/
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_read', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_read
|
||||
@workflow VARCHAR(32),
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@payload_json NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户只读业务适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
数据库兼容级别低于 130 时,ERP 进程先以严格 JSON 规则解析输入,再调用这个
|
||||
固定标量参数过程。占位实现始终拒绝;客户 DBA 必须独立评审并替换只读实现。
|
||||
兼容路径只为 leave 和 purchase 提供下方固定类型写过程;purchase 明细和来源
|
||||
附件摘要使用由受信任 ERP 进程生成、数据库再次按固定 schema 校验的 XML 行集。
|
||||
其他 workflow 在开启事务前拒绝。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_read_compat100', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow VARCHAR(32),
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@reference NVARCHAR(500) = NULL,
|
||||
@tax_id NVARCHAR(50) = NULL,
|
||||
@line_id NVARCHAR(64) = NULL,
|
||||
@specification NVARCHAR(200) = NULL,
|
||||
@unit NVARCHAR(100) = NULL,
|
||||
@supplier_code NVARCHAR(64) = NULL,
|
||||
@currency_code NVARCHAR(64) = NULL,
|
||||
@invoice_number NVARCHAR(128) = NULL,
|
||||
@query NVARCHAR(500) = NULL,
|
||||
@employee_id NVARCHAR(64) = NULL,
|
||||
@local_date NVARCHAR(10) = NULL,
|
||||
@day_part VARCHAR(16) = NULL,
|
||||
@leave_type_code NVARCHAR(64) = NULL,
|
||||
@flow_type_query NVARCHAR(500) = NULL,
|
||||
@calculated_hours DECIMAL(18, 6) = NULL,
|
||||
@flow_type_code NVARCHAR(64) = NULL,
|
||||
@start_local DATETIME = NULL,
|
||||
@end_local DATETIME = NULL,
|
||||
@record_id NVARCHAR(128) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户兼容级别 100 的只读业务适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_write', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_write
|
||||
@workflow VARCHAR(32),
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@correlation_id VARCHAR(128),
|
||||
@payload_json NVARCHAR(MAX),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户事务写业务适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
兼容级别 100 的请假写入口只接受固定强类型标量。占位实现始终拒绝;客户 DBA
|
||||
必须用经过 NOEXEC 复核和独立签署的实现替换。不得增加表名、列名、SQL 文本、
|
||||
过程名或 JSON 参数,也不得在过程内 COMMIT/ROLLBACK 调用方的外层事务。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_write_leave_compat100', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_write_leave_compat100
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@correlation_id VARCHAR(128),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64),
|
||||
@employee_id NVARCHAR(64) = NULL,
|
||||
@leave_type_code NVARCHAR(64) = NULL,
|
||||
@flow_type_code NVARCHAR(64) = NULL,
|
||||
@start_local DATETIME = NULL,
|
||||
@end_local DATETIME = NULL,
|
||||
@requested_hours DECIMAL(18, 6) = NULL,
|
||||
@reason NVARCHAR(500) = NULL,
|
||||
@submit_after_save_intent BIT = NULL,
|
||||
@record_id NVARCHAR(128) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户兼容级别 100 的请假事务写适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
兼容级别 100 的采购写入口只接受固定标量和两个固定 XML 行集。占位实现始终拒绝;
|
||||
客户 DBA 必须在币种字段、币种换算、采购行级权限及 Windows 原保存链验收均签署
|
||||
后,用独立发布制品替换。过程不得接受 JSON、表名、列名、过程名或 SQL 文本,也
|
||||
不得在过程内 COMMIT/ROLLBACK 调用方的外层 Serializable 事务。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_write_purchase_compat100', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_write_purchase_compat100
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@correlation_id VARCHAR(128),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64),
|
||||
@supplier_code NVARCHAR(64),
|
||||
@currency_code NVARCHAR(64),
|
||||
@invoice_number NVARCHAR(128),
|
||||
@invoice_date DATETIME,
|
||||
@total_without_tax DECIMAL(28, 8),
|
||||
@tax_amount DECIMAL(28, 8),
|
||||
@total_with_tax DECIMAL(28, 8),
|
||||
@lines_xml XML,
|
||||
@source_documents_xml XML
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户兼容级别 100 的采购事务写适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
@@ -0,0 +1,107 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_record_workflow_acceptance_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_record_workflow_acceptance_v2
|
||||
@workflow VARCHAR(32),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@adapter_id VARCHAR(128),
|
||||
@adapter_version VARCHAR(64),
|
||||
@evidence_id VARCHAR(128),
|
||||
@evidence_sha256 CHAR(64),
|
||||
@validated_by NVARCHAR(128),
|
||||
@validated_at_utc DATETIME2(3)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @workflow NOT IN (''purchase'', ''leave'')
|
||||
BEGIN
|
||||
RAISERROR(N''workflow 无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF NULLIF(LTRIM(RTRIM(@module_code)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@account_book)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@subsystem_id)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_version)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@evidence_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@validated_by)), N'''') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N''验收证据范围或身份字段不能为空。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF LEN(@evidence_sha256) <> 64 OR @evidence_sha256 LIKE ''%[^0-9a-f]%''
|
||||
BEGIN
|
||||
RAISERROR(N''evidence_sha256 必须是 64 位小写十六进制。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF @validated_at_utc IS NULL
|
||||
OR @validated_at_utc > DATEADD(MINUTE, 5, SYSUTCDATETIME())
|
||||
BEGIN
|
||||
RAISERROR(N''validated_at_utc 无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
UPDATE dbo.p_agent_workflow_adapter_evidence_v2 WITH (UPDLOCK, SERIALIZABLE)
|
||||
SET adapter_id = @adapter_id,
|
||||
adapter_version = @adapter_version,
|
||||
evidence_id = @evidence_id,
|
||||
evidence_sha256 = @evidence_sha256,
|
||||
customer_configuration_validated = 1,
|
||||
parameterized_read_queries_verified = 1,
|
||||
transactional_write_verified = 1,
|
||||
persistent_idempotency_verified = 1,
|
||||
permission_recheck_verified = 1,
|
||||
windows_integration_verified = 1,
|
||||
validated_by = @validated_by,
|
||||
validated_at_utc = @validated_at_utc
|
||||
WHERE workflow = @workflow
|
||||
AND module_code = @module_code
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
INSERT dbo.p_agent_workflow_adapter_evidence_v2
|
||||
(
|
||||
workflow, module_code, account_book, subsystem_id,
|
||||
adapter_id, adapter_version, evidence_id, evidence_sha256,
|
||||
customer_configuration_validated,
|
||||
parameterized_read_queries_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
windows_integration_verified,
|
||||
validated_by, validated_at_utc
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@workflow, @module_code, @account_book, @subsystem_id,
|
||||
@adapter_id, @adapter_version, @evidence_id, @evidence_sha256,
|
||||
1, 1, 1, 1, 1, 1,
|
||||
@validated_by, @validated_at_utc
|
||||
);
|
||||
END;
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT
|
||||
@workflow AS workflow,
|
||||
@module_code AS module_code,
|
||||
@account_book AS account_book,
|
||||
@subsystem_id AS subsystem_id,
|
||||
@evidence_id AS evidence_id,
|
||||
@evidence_sha256 AS evidence_sha256,
|
||||
@validated_at_utc AS validated_at_utc;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
不要把该过程授权给 ERP 日常运行账号。仅由客户 DBA/发布流水线在验收清单已经
|
||||
签名并通过 lserp-cli adapters verify-acceptance-evidence 后调用。
|
||||
*/
|
||||
@@ -0,0 +1,475 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
/*
|
||||
通用低代码能力只固定“安全协议”,不固定客户模块、表或字段。
|
||||
|
||||
- ERP 进程每次从当前登录数据库读取模块配置,向模型只发布
|
||||
m/d + 16 位摘要的不透明参数 ID。
|
||||
- Lookup 过程必须只读;调用方总是在 ReadCommitted 事务中调用并
|
||||
回滚。
|
||||
- 新增过程必须参与调用方已开启的 Serializable 事务,不得在
|
||||
过程内 COMMIT/ROLLBACK 外层事务。
|
||||
- 动态写 v2 对象与v1并存,因为v2增加了原生保存族与执行配置
|
||||
指纹。不会就地改写已部署的v1对象或证据。
|
||||
- 本脚本只创建空证据表和拒绝服务的占位过程,绝不猜测客户业务表。
|
||||
*/
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_dynamic_module_lookup_evidence_v1', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_dynamic_module_lookup_evidence_v1
|
||||
(
|
||||
account_book NVARCHAR(128) NOT NULL,
|
||||
subsystem_id NVARCHAR(128) NOT NULL,
|
||||
database_scope_fingerprint CHAR(64) NOT NULL,
|
||||
evidence_sha256 CHAR(64) NOT NULL,
|
||||
parameterized_reads_verified BIT NOT NULL,
|
||||
permission_recheck_verified BIT NOT NULL,
|
||||
no_side_effects_verified BIT NOT NULL,
|
||||
configuration_binding_verified BIT NOT NULL,
|
||||
windows_integration_verified BIT NOT NULL,
|
||||
maximum_candidates INT NOT NULL,
|
||||
validated_by NVARCHAR(128) NOT NULL,
|
||||
validated_at_utc DATETIME2(3) NOT NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_dynamic_module_lookup_evidence_v1
|
||||
PRIMARY KEY CLUSTERED
|
||||
(account_book, subsystem_id, database_scope_fingerprint),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_lookup_scope_hash_v1
|
||||
CHECK
|
||||
(
|
||||
LEN(database_scope_fingerprint) = 64
|
||||
AND database_scope_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_lookup_evidence_hash_v1
|
||||
CHECK
|
||||
(
|
||||
LEN(evidence_sha256) = 64
|
||||
AND evidence_sha256
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_lookup_candidates_v1
|
||||
CHECK (maximum_candidates BETWEEN 1 AND 20)
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_lookup_readiness_v1', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_lookup_readiness_v1
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT
|
||||
''1.0'' AS schema_version,
|
||||
CAST(CASE WHEN
|
||||
parameterized_reads_verified = 1
|
||||
AND permission_recheck_verified = 1
|
||||
AND no_side_effects_verified = 1
|
||||
AND configuration_binding_verified = 1
|
||||
AND windows_integration_verified = 1
|
||||
THEN 1 ELSE 0 END AS BIT) AS ready,
|
||||
evidence_sha256,
|
||||
validated_at_utc,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
database_scope_fingerprint,
|
||||
parameterized_reads_verified,
|
||||
permission_recheck_verified,
|
||||
no_side_effects_verified,
|
||||
configuration_binding_verified,
|
||||
windows_integration_verified,
|
||||
maximum_candidates
|
||||
FROM dbo.p_agent_dynamic_module_lookup_evidence_v1
|
||||
WHERE account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND database_scope_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 =
|
||||
@database_scope_fingerprint COLLATE Latin1_General_100_BIN2;
|
||||
END;');
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_lookup_read_v1', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_lookup_read_v1
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind NVARCHAR(16),
|
||||
@contract_fingerprint CHAR(64),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@field_configuration_fingerprint CHAR(64),
|
||||
@parameter_id NVARCHAR(17),
|
||||
@scope NVARCHAR(6),
|
||||
@row_number INT,
|
||||
@reference NVARCHAR(2048),
|
||||
@context_xml NVARCHAR(MAX),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@user_id NVARCHAR(128),
|
||||
@correlation_id VARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户动态 Lookup 只读适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_dynamic_module_write_evidence_v2', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_dynamic_module_write_evidence_v2
|
||||
(
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
module_kind VARCHAR(16) NOT NULL,
|
||||
configuration_fingerprint CHAR(64) NOT NULL,
|
||||
native_save_family VARCHAR(64) NOT NULL,
|
||||
native_execution_profile_fingerprint CHAR(64) NOT NULL,
|
||||
account_book NVARCHAR(128) NOT NULL,
|
||||
subsystem_id NVARCHAR(128) NOT NULL,
|
||||
database_scope_fingerprint CHAR(64) NOT NULL,
|
||||
adapter_id VARCHAR(128) NOT NULL,
|
||||
adapter_version VARCHAR(64) NOT NULL,
|
||||
evidence_id VARCHAR(128) NOT NULL,
|
||||
evidence_sha256 CHAR(64) NOT NULL,
|
||||
customer_configuration_validated BIT NOT NULL,
|
||||
native_validation_verified BIT NOT NULL,
|
||||
server_defaults_verified BIT NOT NULL,
|
||||
module_hooks_verified BIT NOT NULL,
|
||||
transactional_write_verified BIT NOT NULL,
|
||||
persistent_idempotency_verified BIT NOT NULL,
|
||||
permission_recheck_verified BIT NOT NULL,
|
||||
configuration_binding_verified BIT NOT NULL,
|
||||
windows_integration_verified BIT NOT NULL,
|
||||
validated_by NVARCHAR(128) NOT NULL,
|
||||
validated_at_utc DATETIME2(3) NOT NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_dynamic_module_write_evidence_v2
|
||||
PRIMARY KEY CLUSTERED
|
||||
(
|
||||
module_code,
|
||||
module_kind,
|
||||
configuration_fingerprint,
|
||||
native_execution_profile_fingerprint,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
database_scope_fingerprint
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_kind_v2
|
||||
CHECK
|
||||
(
|
||||
module_kind COLLATE Latin1_General_100_BIN2
|
||||
IN ('base', 'bill')
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_config_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(configuration_fingerprint) = 64
|
||||
AND configuration_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_native_family_v2
|
||||
CHECK
|
||||
(
|
||||
native_save_family COLLATE Latin1_General_100_BIN2 IN
|
||||
(
|
||||
'legacy.base-save.p-base-save',
|
||||
'legacy.base-save.p-base-save70',
|
||||
'legacy.bill-save.p-bill-save-pr3',
|
||||
'legacy.bill-save.p-bill-save-pr70'
|
||||
)
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_native_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(native_execution_profile_fingerprint) = 64
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_scope_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(database_scope_fingerprint) = 64
|
||||
AND database_scope_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_write_evidence_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(evidence_sha256) = 64
|
||||
AND evidence_sha256
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_write_readiness_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_write_readiness_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT
|
||||
''2.0'' AS schema_version,
|
||||
CAST(CASE WHEN
|
||||
customer_configuration_validated = 1
|
||||
AND native_validation_verified = 1
|
||||
AND server_defaults_verified = 1
|
||||
AND module_hooks_verified = 1
|
||||
AND transactional_write_verified = 1
|
||||
AND persistent_idempotency_verified = 1
|
||||
AND permission_recheck_verified = 1
|
||||
AND configuration_binding_verified = 1
|
||||
AND windows_integration_verified = 1
|
||||
THEN 1 ELSE 0 END AS BIT) AS ready,
|
||||
adapter_id,
|
||||
adapter_version,
|
||||
evidence_id,
|
||||
evidence_sha256,
|
||||
module_code,
|
||||
module_kind,
|
||||
configuration_fingerprint,
|
||||
native_save_family,
|
||||
native_execution_profile_fingerprint,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
database_scope_fingerprint,
|
||||
validated_by,
|
||||
validated_at_utc,
|
||||
customer_configuration_validated,
|
||||
native_validation_verified,
|
||||
server_defaults_verified,
|
||||
module_hooks_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
configuration_binding_verified,
|
||||
windows_integration_verified
|
||||
FROM dbo.p_agent_dynamic_module_write_evidence_v2
|
||||
WHERE module_code COLLATE Latin1_General_100_BIN2 =
|
||||
@module_code COLLATE Latin1_General_100_BIN2
|
||||
AND module_kind COLLATE Latin1_General_100_BIN2 =
|
||||
@module_kind COLLATE Latin1_General_100_BIN2
|
||||
AND configuration_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
AND native_save_family COLLATE Latin1_General_100_BIN2 =
|
||||
@native_save_family COLLATE Latin1_General_100_BIN2
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 =
|
||||
@native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND database_scope_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@database_scope_fingerprint COLLATE Latin1_General_100_BIN2;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
此过程只能由客户 DBA/发布流水线在 Windows 真实 ERP 验收完成后调用,
|
||||
不得授权给 ERP 日常运行账号。@evidence_sha256 必须是同一份
|
||||
TrustedPeople RSA-SHA256 验收清单的 contentSha256,@validated_at_utc
|
||||
必须与清单 issuedAtUtc 一致。进程内验签器会再次精确比较。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_write_acceptance_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_write_acceptance_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64),
|
||||
@adapter_id VARCHAR(128),
|
||||
@adapter_version VARCHAR(64),
|
||||
@evidence_id VARCHAR(128),
|
||||
@evidence_sha256 CHAR(64),
|
||||
@validated_by NVARCHAR(128),
|
||||
@validated_at_utc DATETIME2(3)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @module_kind COLLATE Latin1_General_100_BIN2 NOT IN (''base'', ''bill'')
|
||||
OR NULLIF(LTRIM(RTRIM(@module_code)), N'''') IS NULL
|
||||
OR @native_save_family COLLATE Latin1_General_100_BIN2 NOT IN
|
||||
(
|
||||
''legacy.base-save.p-base-save'',
|
||||
''legacy.base-save.p-base-save70'',
|
||||
''legacy.bill-save.p-bill-save-pr3'',
|
||||
''legacy.bill-save.p-bill-save-pr70''
|
||||
)
|
||||
OR NULLIF(LTRIM(RTRIM(@account_book)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@subsystem_id)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_version)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@evidence_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@validated_by)), N'''') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N''动态写验收的模块、作用域或证据字段无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF LEN(@configuration_fingerprint) <> 64
|
||||
OR @configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@native_execution_profile_fingerprint) <> 64
|
||||
OR @native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@database_scope_fingerprint) <> 64
|
||||
OR @database_scope_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@evidence_sha256) <> 64
|
||||
OR @evidence_sha256 COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
BEGIN
|
||||
RAISERROR(N''动态写验收摘要必须是 64 位小写十六进制。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF @validated_at_utc IS NULL
|
||||
OR @validated_at_utc > DATEADD(MINUTE, 5, SYSUTCDATETIME())
|
||||
OR @validated_at_utc < DATEADD(DAY, -366, SYSUTCDATETIME())
|
||||
BEGIN
|
||||
RAISERROR(N''动态写验收时间无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
UPDATE dbo.p_agent_dynamic_module_write_evidence_v2
|
||||
WITH (UPDLOCK, SERIALIZABLE)
|
||||
SET adapter_id = @adapter_id,
|
||||
adapter_version = @adapter_version,
|
||||
evidence_id = @evidence_id,
|
||||
evidence_sha256 = @evidence_sha256,
|
||||
customer_configuration_validated = 1,
|
||||
native_validation_verified = 1,
|
||||
server_defaults_verified = 1,
|
||||
module_hooks_verified = 1,
|
||||
transactional_write_verified = 1,
|
||||
persistent_idempotency_verified = 1,
|
||||
permission_recheck_verified = 1,
|
||||
configuration_binding_verified = 1,
|
||||
windows_integration_verified = 1,
|
||||
validated_by = @validated_by,
|
||||
validated_at_utc = @validated_at_utc
|
||||
WHERE module_code COLLATE Latin1_General_100_BIN2 =
|
||||
@module_code COLLATE Latin1_General_100_BIN2
|
||||
AND module_kind COLLATE Latin1_General_100_BIN2 =
|
||||
@module_kind COLLATE Latin1_General_100_BIN2
|
||||
AND configuration_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
AND native_save_family COLLATE Latin1_General_100_BIN2 =
|
||||
@native_save_family COLLATE Latin1_General_100_BIN2
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 =
|
||||
@native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND database_scope_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@database_scope_fingerprint COLLATE Latin1_General_100_BIN2;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
INSERT dbo.p_agent_dynamic_module_write_evidence_v2
|
||||
(
|
||||
module_code, module_kind, configuration_fingerprint,
|
||||
native_save_family, native_execution_profile_fingerprint,
|
||||
account_book, subsystem_id, database_scope_fingerprint,
|
||||
adapter_id, adapter_version, evidence_id, evidence_sha256,
|
||||
customer_configuration_validated, native_validation_verified,
|
||||
server_defaults_verified, module_hooks_verified,
|
||||
transactional_write_verified, persistent_idempotency_verified,
|
||||
permission_recheck_verified, configuration_binding_verified,
|
||||
windows_integration_verified, validated_by, validated_at_utc
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@module_code, @module_kind, @configuration_fingerprint,
|
||||
@native_save_family, @native_execution_profile_fingerprint,
|
||||
@account_book, @subsystem_id, @database_scope_fingerprint,
|
||||
@adapter_id, @adapter_version, @evidence_id, @evidence_sha256,
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
@validated_by, @validated_at_utc
|
||||
);
|
||||
END;
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT
|
||||
@module_code AS module_code,
|
||||
@module_kind AS module_kind,
|
||||
@configuration_fingerprint AS configuration_fingerprint,
|
||||
@native_save_family AS native_save_family,
|
||||
@native_execution_profile_fingerprint
|
||||
AS native_execution_profile_fingerprint,
|
||||
@account_book AS account_book,
|
||||
@subsystem_id AS subsystem_id,
|
||||
@database_scope_fingerprint AS database_scope_fingerprint,
|
||||
@evidence_id AS evidence_id,
|
||||
@evidence_sha256 AS evidence_sha256,
|
||||
@validated_at_utc AS validated_at_utc;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
客户实现 p_lserp_agent_module_create_v2 时必须:
|
||||
1. 按 module_code + module_kind 从“当前数据库”重读低代码完整配置;
|
||||
同时按 NewVer 重新解析 native_save_family,并精确核对服务端生成的
|
||||
native_execution_profile_fingerprint;
|
||||
2. 用 parameterId + scope + fieldConfigurationFingerprint 重新映射当前可编辑字段,
|
||||
trustedFieldName 只能用于相等性校验,不得直接拼接成 SQL 标识符;
|
||||
3. 忽略模型对只读字段、编号、审批状态、用户/账套字段的任何伪造,
|
||||
不得把这些字段发布为可编辑参数;
|
||||
4. 在同一外层事务内重做用户/行级权限、原生校验、服务端默认值、
|
||||
自动编号、模块保存钩子、配置指纹、持久幂等和审计;
|
||||
5. 不得 COMMIT/ROLLBACK 调用方外层事务;成功时恰好返回一行下方标准结果。
|
||||
列必须恰好为 success, code, message, record_id, needs_ui,
|
||||
idempotency_replayed, applied_idempotency_key,
|
||||
applied_input_fingerprint, transaction_evidence_id, business_audit_id。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_create_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_create_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@contract_fingerprint CHAR(64),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@values_xml NVARCHAR(MAX),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@user_id NVARCHAR(128),
|
||||
@user_name NVARCHAR(128),
|
||||
@correlation_id VARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户动态模块事务新增适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
生产授权由客户 DBA 显式完成:日常 ERP 账号只能 EXECUTE readiness/read/create,
|
||||
不得对证据表拥有 INSERT/UPDATE/DELETE,也不得 EXECUTE acceptance。
|
||||
*/
|
||||
@@ -0,0 +1,422 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
/*
|
||||
通用修改与通用新增使用独立验收边界。本脚本只允许基础档案 base:
|
||||
|
||||
- snapshot 固定只读过程必须按当前用户权限唯一定位记录,返回完整可编辑
|
||||
参数快照、私有 record_locator、record_version_token 与快照指纹;
|
||||
- update 固定过程由调用方放入 Serializable 事务,必须先检查持久幂等,
|
||||
再锁定目标记录并比较版本和快照,冲突只能返回失败,禁止静默覆盖;
|
||||
- trustedFieldName 只能和当前低代码配置做相等性检查,禁止直接拼 SQL;
|
||||
- 动态更新 v2 对象与v1并存,不就地更改旧证据表或过程;
|
||||
- 本脚本只创建空证据表和拒绝服务占位过程,不读取或修改客户业务表。
|
||||
*/
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_dynamic_module_update_evidence_v2', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.p_agent_dynamic_module_update_evidence_v2
|
||||
(
|
||||
module_code NVARCHAR(64) NOT NULL,
|
||||
module_kind VARCHAR(16) NOT NULL,
|
||||
configuration_fingerprint CHAR(64) NOT NULL,
|
||||
native_save_family VARCHAR(64) NOT NULL,
|
||||
native_execution_profile_fingerprint CHAR(64) NOT NULL,
|
||||
account_book NVARCHAR(128) NOT NULL,
|
||||
subsystem_id NVARCHAR(128) NOT NULL,
|
||||
database_scope_fingerprint CHAR(64) NOT NULL,
|
||||
adapter_id VARCHAR(128) NOT NULL,
|
||||
adapter_version VARCHAR(64) NOT NULL,
|
||||
evidence_id VARCHAR(128) NOT NULL,
|
||||
evidence_sha256 CHAR(64) NOT NULL,
|
||||
customer_configuration_validated BIT NOT NULL,
|
||||
record_resolution_verified BIT NOT NULL,
|
||||
snapshot_binding_verified BIT NOT NULL,
|
||||
optimistic_concurrency_verified BIT NOT NULL,
|
||||
partial_update_verified BIT NOT NULL,
|
||||
native_validation_verified BIT NOT NULL,
|
||||
module_hooks_verified BIT NOT NULL,
|
||||
transactional_write_verified BIT NOT NULL,
|
||||
persistent_idempotency_verified BIT NOT NULL,
|
||||
permission_recheck_verified BIT NOT NULL,
|
||||
configuration_binding_verified BIT NOT NULL,
|
||||
windows_integration_verified BIT NOT NULL,
|
||||
validated_by NVARCHAR(128) NOT NULL,
|
||||
validated_at_utc DATETIME2(3) NOT NULL,
|
||||
row_version ROWVERSION NOT NULL,
|
||||
CONSTRAINT PK_p_agent_dynamic_module_update_evidence_v2
|
||||
PRIMARY KEY CLUSTERED
|
||||
(
|
||||
module_code,
|
||||
module_kind,
|
||||
configuration_fingerprint,
|
||||
native_execution_profile_fingerprint,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
database_scope_fingerprint
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_kind_v2
|
||||
CHECK
|
||||
(
|
||||
module_kind COLLATE Latin1_General_100_BIN2 = 'base'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_config_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(configuration_fingerprint) = 64
|
||||
AND configuration_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_native_family_v2
|
||||
CHECK
|
||||
(
|
||||
native_save_family COLLATE Latin1_General_100_BIN2 IN
|
||||
(
|
||||
'legacy.base-save.p-base-save',
|
||||
'legacy.base-save.p-base-save70'
|
||||
)
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_native_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(native_execution_profile_fingerprint) = 64
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_scope_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(database_scope_fingerprint) = 64
|
||||
AND database_scope_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
),
|
||||
CONSTRAINT CK_p_agent_dynamic_module_update_evidence_hash_v2
|
||||
CHECK
|
||||
(
|
||||
LEN(evidence_sha256) = 64
|
||||
AND evidence_sha256
|
||||
COLLATE Latin1_General_100_BIN2 NOT LIKE '%[^0-9a-f]%'
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_update_readiness_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_update_readiness_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT
|
||||
''2.0'' AS schema_version,
|
||||
CAST(CASE WHEN
|
||||
customer_configuration_validated = 1
|
||||
AND record_resolution_verified = 1
|
||||
AND snapshot_binding_verified = 1
|
||||
AND optimistic_concurrency_verified = 1
|
||||
AND partial_update_verified = 1
|
||||
AND native_validation_verified = 1
|
||||
AND module_hooks_verified = 1
|
||||
AND transactional_write_verified = 1
|
||||
AND persistent_idempotency_verified = 1
|
||||
AND permission_recheck_verified = 1
|
||||
AND configuration_binding_verified = 1
|
||||
AND windows_integration_verified = 1
|
||||
THEN 1 ELSE 0 END AS BIT) AS ready,
|
||||
adapter_id,
|
||||
adapter_version,
|
||||
evidence_id,
|
||||
evidence_sha256,
|
||||
module_code,
|
||||
module_kind,
|
||||
configuration_fingerprint,
|
||||
native_save_family,
|
||||
native_execution_profile_fingerprint,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
database_scope_fingerprint,
|
||||
validated_by,
|
||||
validated_at_utc,
|
||||
customer_configuration_validated,
|
||||
record_resolution_verified,
|
||||
snapshot_binding_verified,
|
||||
optimistic_concurrency_verified,
|
||||
partial_update_verified,
|
||||
native_validation_verified,
|
||||
module_hooks_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
configuration_binding_verified,
|
||||
windows_integration_verified
|
||||
FROM dbo.p_agent_dynamic_module_update_evidence_v2
|
||||
WHERE module_code COLLATE Latin1_General_100_BIN2 =
|
||||
@module_code COLLATE Latin1_General_100_BIN2
|
||||
AND module_kind COLLATE Latin1_General_100_BIN2 =
|
||||
@module_kind COLLATE Latin1_General_100_BIN2
|
||||
AND configuration_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
AND native_save_family COLLATE Latin1_General_100_BIN2 =
|
||||
@native_save_family COLLATE Latin1_General_100_BIN2
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 =
|
||||
@native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND database_scope_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@database_scope_fingerprint COLLATE Latin1_General_100_BIN2;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
只允许客户 DBA/发布流水线调用 acceptance;不得授权给 ERP 日常账号。
|
||||
@evidence_sha256 必须等于更新专用 TrustedPeople 签名清单的
|
||||
contentSha256,@validated_at_utc 必须与 issuedAtUtc 一致。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_update_acceptance_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_update_acceptance_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64),
|
||||
@adapter_id VARCHAR(128),
|
||||
@adapter_version VARCHAR(64),
|
||||
@evidence_id VARCHAR(128),
|
||||
@evidence_sha256 CHAR(64),
|
||||
@validated_by NVARCHAR(128),
|
||||
@validated_at_utc DATETIME2(3)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @module_kind COLLATE Latin1_General_100_BIN2 <> ''base''
|
||||
OR NULLIF(LTRIM(RTRIM(@module_code)), N'''') IS NULL
|
||||
OR @native_save_family COLLATE Latin1_General_100_BIN2 NOT IN
|
||||
(
|
||||
''legacy.base-save.p-base-save'',
|
||||
''legacy.base-save.p-base-save70''
|
||||
)
|
||||
OR NULLIF(LTRIM(RTRIM(@account_book)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@subsystem_id)), N'''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@adapter_version)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@evidence_id)), '''') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@validated_by)), N'''') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N''动态更新验收的模块、作用域或证据字段无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF LEN(@configuration_fingerprint) <> 64
|
||||
OR @configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@native_execution_profile_fingerprint) <> 64
|
||||
OR @native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@database_scope_fingerprint) <> 64
|
||||
OR @database_scope_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
OR LEN(@evidence_sha256) <> 64
|
||||
OR @evidence_sha256 COLLATE Latin1_General_100_BIN2
|
||||
LIKE ''%[^0-9a-f]%''
|
||||
BEGIN
|
||||
RAISERROR(N''动态更新验收摘要必须是 64 位小写十六进制。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
IF @validated_at_utc IS NULL
|
||||
OR @validated_at_utc > DATEADD(MINUTE, 5, SYSUTCDATETIME())
|
||||
OR @validated_at_utc < DATEADD(DAY, -366, SYSUTCDATETIME())
|
||||
BEGIN
|
||||
RAISERROR(N''动态更新验收时间无效。'', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
UPDATE dbo.p_agent_dynamic_module_update_evidence_v2
|
||||
WITH (UPDLOCK, SERIALIZABLE)
|
||||
SET adapter_id = @adapter_id,
|
||||
adapter_version = @adapter_version,
|
||||
evidence_id = @evidence_id,
|
||||
evidence_sha256 = @evidence_sha256,
|
||||
customer_configuration_validated = 1,
|
||||
record_resolution_verified = 1,
|
||||
snapshot_binding_verified = 1,
|
||||
optimistic_concurrency_verified = 1,
|
||||
partial_update_verified = 1,
|
||||
native_validation_verified = 1,
|
||||
module_hooks_verified = 1,
|
||||
transactional_write_verified = 1,
|
||||
persistent_idempotency_verified = 1,
|
||||
permission_recheck_verified = 1,
|
||||
configuration_binding_verified = 1,
|
||||
windows_integration_verified = 1,
|
||||
validated_by = @validated_by,
|
||||
validated_at_utc = @validated_at_utc
|
||||
WHERE module_code COLLATE Latin1_General_100_BIN2 =
|
||||
@module_code COLLATE Latin1_General_100_BIN2
|
||||
AND module_kind COLLATE Latin1_General_100_BIN2 =
|
||||
@module_kind COLLATE Latin1_General_100_BIN2
|
||||
AND configuration_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@configuration_fingerprint COLLATE Latin1_General_100_BIN2
|
||||
AND native_save_family COLLATE Latin1_General_100_BIN2 =
|
||||
@native_save_family COLLATE Latin1_General_100_BIN2
|
||||
AND native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2 =
|
||||
@native_execution_profile_fingerprint
|
||||
COLLATE Latin1_General_100_BIN2
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND database_scope_fingerprint COLLATE Latin1_General_100_BIN2 =
|
||||
@database_scope_fingerprint COLLATE Latin1_General_100_BIN2;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
INSERT dbo.p_agent_dynamic_module_update_evidence_v2
|
||||
(
|
||||
module_code, module_kind, configuration_fingerprint,
|
||||
native_save_family, native_execution_profile_fingerprint,
|
||||
account_book, subsystem_id, database_scope_fingerprint,
|
||||
adapter_id, adapter_version, evidence_id, evidence_sha256,
|
||||
customer_configuration_validated, record_resolution_verified,
|
||||
snapshot_binding_verified, optimistic_concurrency_verified,
|
||||
partial_update_verified, native_validation_verified,
|
||||
module_hooks_verified, transactional_write_verified,
|
||||
persistent_idempotency_verified, permission_recheck_verified,
|
||||
configuration_binding_verified, windows_integration_verified,
|
||||
validated_by, validated_at_utc
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@module_code, @module_kind, @configuration_fingerprint,
|
||||
@native_save_family, @native_execution_profile_fingerprint,
|
||||
@account_book, @subsystem_id, @database_scope_fingerprint,
|
||||
@adapter_id, @adapter_version, @evidence_id, @evidence_sha256,
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
@validated_by, @validated_at_utc
|
||||
);
|
||||
END;
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT
|
||||
@module_code AS module_code,
|
||||
@configuration_fingerprint AS configuration_fingerprint,
|
||||
@native_save_family AS native_save_family,
|
||||
@native_execution_profile_fingerprint
|
||||
AS native_execution_profile_fingerprint,
|
||||
@account_book AS account_book,
|
||||
@subsystem_id AS subsystem_id,
|
||||
@database_scope_fingerprint AS database_scope_fingerprint,
|
||||
@evidence_id AS evidence_id,
|
||||
@evidence_sha256 AS evidence_sha256,
|
||||
@validated_at_utc AS validated_at_utc;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
客户实现 snapshot 时必须:
|
||||
1. 只按当前模块白名单配置和参数化 record_query 查询,禁止动态 SQL;
|
||||
2. 复核当前用户、账套、子系统、数据库作用域及菜单/行级查看与修改权限;
|
||||
3. 零条返回 matched=0 + dynamic_module_update_record_not_found,多条返回
|
||||
matched=0 + dynamic_module_update_record_ambiguous,禁止任取第一条;
|
||||
4. 唯一记录返回完整可编辑参数全集,values_xml 根必须是
|
||||
<moduleUpdateSnapshot schemaVersion="1.0">,每个 value 只含 parameterId;
|
||||
5. record_locator 只供固定 update 过程解释;version_token 和 snapshot 指纹
|
||||
必须绑定锁前的完整持久记录状态,不能使用时间戳猜测或客户端输入;
|
||||
6. 不得产生业务副作用。调用方会回滚整个 ReadCommitted 事务。
|
||||
|
||||
结果 schema_version 必须为 2.0,列必须恰好为 schema_version, matched, code, message, module_code,
|
||||
module_kind, contract_fingerprint, configuration_fingerprint, record_locator,
|
||||
record_display, record_version_token, record_snapshot_fingerprint, values_xml。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_update_snapshot_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_update_snapshot_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@contract_fingerprint CHAR(64),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@record_query NVARCHAR(256),
|
||||
@changes_xml NVARCHAR(MAX),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@user_id NVARCHAR(128),
|
||||
@user_name NVARCHAR(128),
|
||||
@correlation_id VARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户动态模块记录快照适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
客户实现 update 时必须:
|
||||
1. 首先按 (作用域, 用户, 幂等键) 在持久幂等表中检查完全相同的
|
||||
input_fingerprint;已提交重放直接返回原事务/审计证据;
|
||||
2. 从当前数据库重读模块、字段配置和权限,只允许 base;用 parameterId +
|
||||
fieldConfigurationFingerprint 映射字段,trustedFieldName 仅作相等校验;
|
||||
同时按 NewVer 重新解析并核对 native_save_family 与
|
||||
native_execution_profile_fingerprint;
|
||||
3. 用 UPDLOCK/HOLDLOCK 唯一锁定 record_locator 指向的记录,再重新计算
|
||||
version_token 与完整 snapshot 指纹。任一不一致返回
|
||||
success=0/code=dynamic_module_update_conflict,绝不能写入或自动合并;
|
||||
4. 把变更合并进完整当前记录,执行原生必填/类型/业务校验和模块保存钩子;
|
||||
只更新实际变更字段,不允许修改主键、编号、状态、创建人或系统字段;
|
||||
5. 更新、持久幂等完成、事务证据和业务审计必须处于调用方同一
|
||||
Serializable 事务;过程不得 COMMIT/ROLLBACK 外层事务;
|
||||
6. 成功时恰好返回标准十列:success, code, message, record_id,
|
||||
needs_ui, idempotency_replayed, applied_idempotency_key,
|
||||
applied_input_fingerprint, transaction_evidence_id, business_audit_id。
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_module_update_v2', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_module_update_v2
|
||||
@module_code NVARCHAR(64),
|
||||
@module_kind VARCHAR(16),
|
||||
@contract_fingerprint CHAR(64),
|
||||
@configuration_fingerprint CHAR(64),
|
||||
@native_save_family VARCHAR(64),
|
||||
@native_execution_profile_fingerprint CHAR(64),
|
||||
@record_locator NVARCHAR(512),
|
||||
@record_version_token CHAR(64),
|
||||
@record_snapshot_fingerprint CHAR(64),
|
||||
@changes_xml NVARCHAR(MAX),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128),
|
||||
@user_id NVARCHAR(128),
|
||||
@user_name NVARCHAR(128),
|
||||
@correlation_id VARCHAR(128),
|
||||
@database_scope_fingerprint CHAR(64),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
RAISERROR(N''客户动态模块并发更新适配过程尚未实施。'', 16, 1);
|
||||
RETURN;
|
||||
END;');
|
||||
END;
|
||||
|
||||
/*
|
||||
生产授权由客户 DBA 显式完成:ERP 日常账号只能 EXECUTE readiness、snapshot、
|
||||
update;不得直接写证据表,也不得 EXECUTE acceptance。snapshot 账号不得
|
||||
取得任何业务表写权限,update 账号只通过固定过程获得最小权限。
|
||||
*/
|
||||
@@ -0,0 +1,244 @@
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
/*
|
||||
V3 readiness refuses to publish an already accepted adapter after either deployed
|
||||
read/write procedure changes. It also compares the exact ordered SQL parameter
|
||||
signature before returning the V2 evidence row. The procedure reads only system
|
||||
catalogs and the Agent evidence table; it never reads or mutates business rows.
|
||||
*/
|
||||
IF OBJECT_ID(N'dbo.p_lserp_agent_workflow_readiness_v3', N'P') IS NULL
|
||||
BEGIN
|
||||
EXEC(N'
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_readiness_v3
|
||||
@workflow VARCHAR(32),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
END;');
|
||||
END;
|
||||
GO
|
||||
|
||||
ALTER PROCEDURE dbo.p_lserp_agent_workflow_readiness_v3
|
||||
@workflow VARCHAR(32),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(128),
|
||||
@subsystem_id NVARCHAR(128)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @workflow NOT IN ('purchase', 'leave')
|
||||
OR NULLIF(LTRIM(RTRIM(@module_code)), N'') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@account_book)), N'') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@subsystem_id)), N'') IS NULL
|
||||
BEGIN
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @compatibility_level INT;
|
||||
SELECT @compatibility_level = compatibility_level
|
||||
FROM sys.databases
|
||||
WHERE database_id = DB_ID();
|
||||
IF @compatibility_level IS NULL
|
||||
BEGIN
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @read_procedure SYSNAME;
|
||||
DECLARE @write_procedure SYSNAME;
|
||||
DECLARE @expected_read_signature NVARCHAR(MAX);
|
||||
DECLARE @expected_write_signature NVARCHAR(MAX);
|
||||
|
||||
IF @compatibility_level < 130
|
||||
BEGIN
|
||||
SET @read_procedure = N'dbo.p_lserp_agent_workflow_read_compat100';
|
||||
SET @expected_read_signature =
|
||||
N'1:@workflow:varchar(32):0|2:@action:varchar(64):0|'
|
||||
+ N'3:@module_code:nvarchar(64):0|4:@account_book:nvarchar(64):0|'
|
||||
+ N'5:@subsystem_id:nvarchar(32):0|6:@user_id:nvarchar(64):0|'
|
||||
+ N'7:@reference:nvarchar(500):0|8:@tax_id:nvarchar(50):0|'
|
||||
+ N'9:@line_id:nvarchar(64):0|10:@specification:nvarchar(200):0|'
|
||||
+ N'11:@unit:nvarchar(100):0|12:@supplier_code:nvarchar(64):0|'
|
||||
+ N'13:@currency_code:nvarchar(64):0|14:@invoice_number:nvarchar(128):0|'
|
||||
+ N'15:@query:nvarchar(500):0|16:@employee_id:nvarchar(64):0|'
|
||||
+ N'17:@local_date:nvarchar(10):0|18:@day_part:varchar(16):0|'
|
||||
+ N'19:@leave_type_code:nvarchar(64):0|'
|
||||
+ N'20:@flow_type_query:nvarchar(500):0|'
|
||||
+ N'21:@calculated_hours:decimal(18,6):0|'
|
||||
+ N'22:@flow_type_code:nvarchar(64):0|23:@start_local:datetime:0|'
|
||||
+ N'24:@end_local:datetime:0|25:@record_id:nvarchar(128):0';
|
||||
|
||||
IF @workflow = 'purchase'
|
||||
BEGIN
|
||||
SET @write_procedure =
|
||||
N'dbo.p_lserp_agent_workflow_write_purchase_compat100';
|
||||
SET @expected_write_signature =
|
||||
N'1:@action:varchar(64):0|2:@module_code:nvarchar(64):0|'
|
||||
+ N'3:@account_book:nvarchar(64):0|4:@subsystem_id:nvarchar(32):0|'
|
||||
+ N'5:@user_id:nvarchar(64):0|6:@correlation_id:varchar(128):0|'
|
||||
+ N'7:@idempotency_key:varchar(128):0|'
|
||||
+ N'8:@input_fingerprint:char(64):0|'
|
||||
+ N'9:@supplier_code:nvarchar(64):0|'
|
||||
+ N'10:@currency_code:nvarchar(64):0|'
|
||||
+ N'11:@invoice_number:nvarchar(128):0|12:@invoice_date:datetime:0|'
|
||||
+ N'13:@total_without_tax:decimal(28,8):0|'
|
||||
+ N'14:@tax_amount:decimal(28,8):0|'
|
||||
+ N'15:@total_with_tax:decimal(28,8):0|16:@lines_xml:xml:0|'
|
||||
+ N'17:@source_documents_xml:xml:0';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SET @write_procedure =
|
||||
N'dbo.p_lserp_agent_workflow_write_leave_compat100';
|
||||
SET @expected_write_signature =
|
||||
N'1:@action:varchar(64):0|2:@module_code:nvarchar(64):0|'
|
||||
+ N'3:@account_book:nvarchar(64):0|4:@subsystem_id:nvarchar(32):0|'
|
||||
+ N'5:@user_id:nvarchar(64):0|6:@correlation_id:varchar(128):0|'
|
||||
+ N'7:@idempotency_key:varchar(128):0|'
|
||||
+ N'8:@input_fingerprint:char(64):0|'
|
||||
+ N'9:@employee_id:nvarchar(64):0|'
|
||||
+ N'10:@leave_type_code:nvarchar(64):0|'
|
||||
+ N'11:@flow_type_code:nvarchar(64):0|12:@start_local:datetime:0|'
|
||||
+ N'13:@end_local:datetime:0|'
|
||||
+ N'14:@requested_hours:decimal(18,6):0|'
|
||||
+ N'15:@reason:nvarchar(500):0|'
|
||||
+ N'16:@submit_after_save_intent:bit:0|'
|
||||
+ N'17:@record_id:nvarchar(128):0';
|
||||
END;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SET @read_procedure = N'dbo.p_lserp_agent_workflow_read';
|
||||
SET @write_procedure = N'dbo.p_lserp_agent_workflow_write';
|
||||
SET @expected_read_signature =
|
||||
N'1:@workflow:varchar(32):0|2:@action:varchar(64):0|'
|
||||
+ N'3:@module_code:nvarchar(64):0|4:@account_book:nvarchar(64):0|'
|
||||
+ N'5:@subsystem_id:nvarchar(32):0|6:@user_id:nvarchar(64):0|'
|
||||
+ N'7:@payload_json:nvarchar(max):0';
|
||||
SET @expected_write_signature =
|
||||
N'1:@workflow:varchar(32):0|2:@action:varchar(64):0|'
|
||||
+ N'3:@module_code:nvarchar(64):0|4:@account_book:nvarchar(64):0|'
|
||||
+ N'5:@subsystem_id:nvarchar(32):0|6:@user_id:nvarchar(64):0|'
|
||||
+ N'7:@correlation_id:varchar(128):0|'
|
||||
+ N'8:@payload_json:nvarchar(max):0|'
|
||||
+ N'9:@idempotency_key:varchar(128):0|'
|
||||
+ N'10:@input_fingerprint:char(64):0';
|
||||
END;
|
||||
|
||||
DECLARE @read_object_id INT;
|
||||
DECLARE @write_object_id INT;
|
||||
SET @read_object_id = OBJECT_ID(@read_procedure, N'P');
|
||||
SET @write_object_id = OBJECT_ID(@write_procedure, N'P');
|
||||
IF @read_object_id IS NULL OR @write_object_id IS NULL
|
||||
BEGIN
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @actual_read_signature NVARCHAR(MAX);
|
||||
DECLARE @actual_write_signature NVARCHAR(MAX);
|
||||
SELECT @actual_read_signature = STUFF
|
||||
((
|
||||
SELECT
|
||||
N'|' + CONVERT(NVARCHAR(10), contract_parameter.parameter_id)
|
||||
+ N':' + contract_parameter.name + N':'
|
||||
+ LOWER(TYPE_NAME(contract_parameter.system_type_id))
|
||||
+ CASE
|
||||
WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'varchar', N'char', N'nvarchar', N'nchar')
|
||||
THEN N'(' + CASE WHEN contract_parameter.max_length = -1
|
||||
THEN N'max' ELSE CONVERT(NVARCHAR(10),
|
||||
CASE WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'nvarchar', N'nchar')
|
||||
THEN contract_parameter.max_length / 2
|
||||
ELSE contract_parameter.max_length END) END + N')'
|
||||
WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'decimal', N'numeric')
|
||||
THEN N'(' + CONVERT(NVARCHAR(10), contract_parameter.precision)
|
||||
+ N',' + CONVERT(NVARCHAR(10), contract_parameter.scale) + N')'
|
||||
ELSE N''
|
||||
END
|
||||
+ N':' + CONVERT(NVARCHAR(1), contract_parameter.is_output)
|
||||
FROM sys.parameters AS contract_parameter
|
||||
WHERE contract_parameter.object_id = @read_object_id
|
||||
AND contract_parameter.parameter_id > 0
|
||||
ORDER BY contract_parameter.parameter_id
|
||||
FOR XML PATH(N''), TYPE
|
||||
).value(N'.', N'nvarchar(max)'), 1, 1, N'');
|
||||
|
||||
SELECT @actual_write_signature = STUFF
|
||||
((
|
||||
SELECT
|
||||
N'|' + CONVERT(NVARCHAR(10), contract_parameter.parameter_id)
|
||||
+ N':' + contract_parameter.name + N':'
|
||||
+ LOWER(TYPE_NAME(contract_parameter.system_type_id))
|
||||
+ CASE
|
||||
WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'varchar', N'char', N'nvarchar', N'nchar')
|
||||
THEN N'(' + CASE WHEN contract_parameter.max_length = -1
|
||||
THEN N'max' ELSE CONVERT(NVARCHAR(10),
|
||||
CASE WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'nvarchar', N'nchar')
|
||||
THEN contract_parameter.max_length / 2
|
||||
ELSE contract_parameter.max_length END) END + N')'
|
||||
WHEN TYPE_NAME(contract_parameter.system_type_id)
|
||||
IN (N'decimal', N'numeric')
|
||||
THEN N'(' + CONVERT(NVARCHAR(10), contract_parameter.precision)
|
||||
+ N',' + CONVERT(NVARCHAR(10), contract_parameter.scale) + N')'
|
||||
ELSE N''
|
||||
END
|
||||
+ N':' + CONVERT(NVARCHAR(1), contract_parameter.is_output)
|
||||
FROM sys.parameters AS contract_parameter
|
||||
WHERE contract_parameter.object_id = @write_object_id
|
||||
AND contract_parameter.parameter_id > 0
|
||||
ORDER BY contract_parameter.parameter_id
|
||||
FOR XML PATH(N''), TYPE
|
||||
).value(N'.', N'nvarchar(max)'), 1, 1, N'');
|
||||
|
||||
IF ISNULL(@actual_read_signature, N'') COLLATE Latin1_General_100_BIN2
|
||||
<> @expected_read_signature COLLATE Latin1_General_100_BIN2
|
||||
OR ISNULL(@actual_write_signature, N'') COLLATE Latin1_General_100_BIN2
|
||||
<> @expected_write_signature COLLATE Latin1_General_100_BIN2
|
||||
BEGIN
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
/* sys.objects.modify_date is server-local; convert using the current server
|
||||
UTC offset. A DST-boundary ambiguity can only fail closed and require a
|
||||
fresh acceptance row. */
|
||||
DECLARE @latest_contract_modified_local DATETIME;
|
||||
DECLARE @latest_contract_modified_utc DATETIME2(3);
|
||||
SELECT @latest_contract_modified_local = MAX(modify_date)
|
||||
FROM sys.procedures
|
||||
WHERE object_id IN (@read_object_id, @write_object_id);
|
||||
SET @latest_contract_modified_utc = CONVERT(DATETIME2(3), DATEADD(
|
||||
MINUTE,
|
||||
DATEDIFF(MINUTE, GETDATE(), GETUTCDATE()),
|
||||
@latest_contract_modified_local));
|
||||
|
||||
SELECT
|
||||
adapter_id,
|
||||
adapter_version,
|
||||
evidence_id,
|
||||
evidence_sha256,
|
||||
account_book,
|
||||
subsystem_id,
|
||||
customer_configuration_validated,
|
||||
parameterized_read_queries_verified,
|
||||
transactional_write_verified,
|
||||
persistent_idempotency_verified,
|
||||
permission_recheck_verified,
|
||||
windows_integration_verified,
|
||||
validated_by,
|
||||
validated_at_utc
|
||||
FROM dbo.p_agent_workflow_adapter_evidence_v2
|
||||
WHERE workflow = @workflow
|
||||
AND module_code = @module_code
|
||||
AND account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND validated_at_utc >= @latest_contract_modified_utc
|
||||
AND validated_at_utc <= DATEADD(MINUTE, 5, SYSUTCDATETIME());
|
||||
END;
|
||||
GO
|
||||
@@ -0,0 +1,65 @@
|
||||
# 第三方组件商用合规门禁
|
||||
|
||||
本文是发布工程清单,不代替律师意见。最终商用部署必须由公司法务或经授权的开源合规负责人审查实际交付方式、修改内容、客户访问范围及供应商条款,并形成不可变的 PDF 或 P7S 证据。
|
||||
|
||||
## AstrBot 4.27.2
|
||||
|
||||
- 官方来源:`https://github.com/AstrBotDevs/AstrBot.git`
|
||||
- 锁定标签:`v4.27.2`
|
||||
- 锁定提交:`ad4fbfa90ca0c4ac2b30b3250e34dbf8fe7babbf`
|
||||
- `pyproject.toml` 声明:`AGPL-3.0-or-later`
|
||||
- 官方 `LICENSE` SHA-256:`ccf7d08f932af3e813848881731113afbb7c80d0fd6d958e8d319002bf344d02`
|
||||
- 官方 `EULA.md` SHA-256:`c332de7781e87c67d6d3beda463fa04705075a6bae9e52a252f7c639f6defd80`
|
||||
|
||||
AstrBot 本体不装入朗速桌宠 ZIP,但它是必需的独立运行服务。审查证据至少要说明:使用原版还是修改版、由客户本地运行还是朗速提供网络服务、是否向客户传递对象代码、相应源代码和安装信息如何提供、远程用户如何取得适用源码、EULA 如何告知和接受、上游更新如何重新审查。若这些问题没有形成书面结论,不得把部署标记为商用就绪。
|
||||
|
||||
建议在客户实例设置 `ASTRBOT_DISABLE_METRICS=1`,并把遥测、会话、附件、模型供应商和个人信息处理写入客户数据处理清单。
|
||||
|
||||
## MiniMax API / VLM
|
||||
|
||||
- 官方功能文档:`https://platform.minimax.io/docs/guides/token-plan-mcp-guide`
|
||||
- 官方来源:`https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP.git`
|
||||
- 锁定版本:`minimax-coding-plan-mcp 0.0.4`
|
||||
- 锁定提交:`fbac3b3e56922a1249e00eebe07d9ee68f4768dc`
|
||||
- 官方 `LICENSE` SHA-256:`a9138c01f3c22641ac8f8fe2f3ec75de0b4a0494ff81e8da43b3cc2091935ac3`(MIT)
|
||||
- 官方 `minimax_mcp/client.py` SHA-256:`08d4116a20e8a652ceb9e2b6f58b1e7cdfe464b14baff05977e08b4b05b66be3`
|
||||
- 官方 `minimax_mcp/server.py` SHA-256:`1dea28d6ba4ee46ba516d7eeedd325a5a102410bb7abb074fc4b0a8a66571864`
|
||||
- PyPI 0.0.4 wheel SHA-256:`ef20ded2c716dfb33a446f8608b58d5fc3a8f76db744f1805d1b412906622572`
|
||||
|
||||
朗速交付包不再分发 `minimax-coding-plan-mcp`、`mmx-cli`、Node.js、uvx 或其 Python 运行依赖。图片识别由 AstrBot 服务进程以标准库 HTTPS 直接实现上述官方 0.0.4 源码中的最小 VLM 线协议:`global` 固定到 `https://api.minimax.io/v1/coding_plan/vlm`,`cn` 固定到 `https://api.minimaxi.com/v1/coding_plan/vlm`,并发送官方客户端使用的 `MM-API-Source: Minimax-MCP`。插件禁止自定义 URL、环境代理和重定向,并对请求、响应大小、Content-Type、供应商状态和业务 JSON 做失败关闭校验。该端点由 MiniMax 官方 MCP 源码使用,但当前未列入公开 OpenAPI;升级版本、源码提交、端点、请求头或响应结构前必须重新取证并执行客户服务账号的合成图片在线探针。该能力仍属于在线 MiniMax 服务,不是朗速本地模型,也不应被描述成无需供应商合同的离线能力。
|
||||
|
||||
移除 CLI 再分发不等于 MiniMax 模型/API 已取得商用授权。书面审查证据仍须覆盖目标区域的账号主体、套餐和计费、API 服务条款、可用性/版本变更、数据处理与保留、跨境、发票和员工信息、密钥轮换及事件响应。若这些结论没有形成书面记录,不得把 MiniMax 图片识别标记为客户生产就绪。
|
||||
|
||||
## pypdfium2 5.12.1 / PDFium
|
||||
|
||||
- 官方项目:`https://github.com/pypdfium2-team/pypdfium2`
|
||||
- 官方发布:`https://pypi.org/project/pypdfium2/5.12.1/`
|
||||
- 锁定版本:`5.12.1`
|
||||
- Windows x64 wheel:`pypdfium2-5.12.1-py3-none-win_amd64.whl`
|
||||
- Windows x64 wheel SHA-256:`9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca`
|
||||
- 项目许可证表达:`BSD-3-Clause OR Apache-2.0`
|
||||
|
||||
朗速商用包会再分发上述精确 Windows wheel,用于把电子 PDF 在隔离 worker 中渲染为受限 RGB PNG。该 wheel 内必须同时存在 `pypdfium2_raw/pdfium.dll`、Apache-2.0/BSD-3-Clause/CC-BY-4.0 文本,以及 `data/windows_x64/BUILD_LICENSES` 下的 PDFium 和第三方依赖声明;现场 `pdf_invoice_pipeline` 门禁会复核 wheel 文件名、SHA-256、原生库和关键许可证条目。构建脚本分别建立本机测试 wheelhouse 与 Windows 交付 wheelhouse,禁止因在 macOS/Linux 构建而把错误平台的 PDFium 二进制装入客户包。
|
||||
|
||||
wheel 自带许可证不等于公司已经完成商用审查。正式发布仍须把该精确 wheel、PDFium 原生库及其传递依赖纳入企业 SBOM、恶意软件扫描、漏洞监测、许可证公告和升级评估;法务或开源合规负责人应确认客户再分发方式和所需 notice。任何版本、wheel 哈希、目标平台或许可证目录变化都必须视为新的供应链审查,不得只修改 `requirements.txt` 后继续沿用旧结论。
|
||||
|
||||
## guga / codex-pets 素材
|
||||
|
||||
2026-08-13 的只读上游审计已固化在 `guga-upstream-audit.v1.json`:用户给出的 npm 安装器是 `codex-pets 0.3.0`,npm tarball SHA-1 为 `82e41349ae63eb9e63099f2e06a56468182e2c90`、SHA-256 为 `9ec8bf1ea09e6d8fdc17b33a594a178a9b20bd3dc6decbb22973758394c9c1c7`。该包声明 MIT,但没有声明代码仓库;`add guga` 从 `https://codex-pets.net` 下载在线可变包,只向 `$CODEX_HOME/pets/guga` 写 `pet.json` 和 `spritesheet.webp`,不验证素材摘要、签名或授权。
|
||||
|
||||
服务端公开源码 `portons/codex-pet-share` 在提交 `22725091da2787e8e525c9289cb7826a34be4950` 声明的 MIT 只证明服务软件许可。其 2026-05-09 条款要求上传者具有公开分享权,并允许服务展示和下载上传物,但没有向朗速授予商业产品使用、客户部署或再分发许可。审计时 guga 页面标注上传者为 `CIRCUS/circus`;下载 ZIP SHA-256 为 `3ebd971ba59a0c988a6be0924669b4c5db9234bcc5d17d506e34eba332e6021f`,其中精灵图 SHA-256 为 `1b61ea2af98717b9ebe55beb4c6b820b89e9c42d4fdfeca21cf63ed3ad4e38da`,API、`pet.json` 和 ZIP 均没有素材许可证字段或许可证文件。因此“CLI/网站代码 MIT”不得解释成“guga 图片可商用”。
|
||||
|
||||
构建和现场预检以 `guga_supply_chain_audit` 复核上述 npm、服务源码和素材快照,并按文件名与内容摘要拒绝把审计到的在线 guga 包、manifest 或 spritesheet 夹带进桌宠 ZIP。生产机不得直接运行 `npx codex-pets add guga`;取得权利人授权后,应通过公司受控制品渠道交付已查毒、已哈希的包外素材,并让同一份书面授权明确绑定实际素材 SHA-256。
|
||||
|
||||
精灵图不在包内。上线仍须取得权利人的书面商用授权,明确权利人身份、商业产品使用、复制、客户部署、产品展示、地域、期限以及是否允许修改或再分发;仅有 npm 安装成功、仓库可访问、上传者名称、公开下载或个人使用许可均不能替代授权。
|
||||
|
||||
## 现场证据
|
||||
|
||||
`Verify-LserpCommercialPackage.ps1` 和 `Start-LserpAgentPet.ps1` 要求四个包外证据:
|
||||
|
||||
1. guga 商用授权;
|
||||
2. AstrBot AGPL/EULA 合规审查;
|
||||
3. MiniMax API 服务条款、数据处理、部署区域、套餐与密钥管理审查;
|
||||
4. 同一客户服务账号、同一区域执行随包合成图片在线探针得到的原始 JSON 报告。
|
||||
|
||||
前三项只接受非链接、非空、不超过 16 MB 的 PDF 或 P7S。在线探针只接受随包 `verify_minimax_vlm_contract.py` 以 `CreateNew` 生成的、不超过 64 KB、预检前 24 小时内的严格 JSON;它必须绑定固定无客户数据 PNG 的 SHA-256、官方 MCP 0.0.4 来源提交/源码摘要、`MM-API-Source`、固定区域端点和脱敏结果摘要。商用预检 `schemaVersion=1.7` 记录四份证据的 SHA-256、探针观测时间、区域和合同版本;客户总验收 `schemaVersion=1.8` 再把原始探针纳入 23 个 RSA 签章制品,并分别绑定采购、请假、诊断三个 ERP 会话的 1.5 只读预检、1.1 独立审批交接及最终包内受限运行时 CLI 身份。探针证明当时服务合同可用,不代替服务条款、SLA 或长期可用性承诺。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# 写链路签名验收门禁
|
||||
|
||||
采购与请假写命令不再接受数据库中几个布尔字段作为充分证据。启用命令必须同时满足:
|
||||
|
||||
1. 当前 ERP 账套、子系统和模块通过 V3 运行时就绪查询,并匹配唯一的 V2 验收证据行。
|
||||
2. `evidence_sha256` 与本地签名验收清单的 `contentSha256` 完全一致。
|
||||
3. 清单绑定相同的工作流、模块、账套、子系统、适配器 ID/版本、证据 ID、验证人和验证时间。
|
||||
4. 清单绑定实际部署的 `business-adapters.json`、客户只读画像、字段映射、只读契约证据和 Windows 写集成证据五个文件的 SHA-256。
|
||||
5. 清单由 Windows `TrustedPeople` 中精确 thumbprint 匹配、当前有效的 RSA CSP 证书使用 RSA-SHA256 签名。
|
||||
6. 清单未过期,最长生命周期为 366 天;包括“关键目录运行时复核”在内的七项商用要求必须明确为 `true`。
|
||||
|
||||
门禁在启动注册、生成计划和确认后执行前都会重新读取数据库与清单、验证签名,并用固定只读查询把签名画像与当前 SQL Server 系统目录重新比较。任一文件被替换、证书失效、子系统切换、数据库行变化、关键列/过程参数漂移或目录查询不可用,写命令保持禁用或在写过程调用前停止。
|
||||
|
||||
## 1. 证书准备
|
||||
|
||||
- 使用客户验收/发布专用 RSA CSP 代码签名证书。
|
||||
- 私钥只保留在受控签发机,不安装到普通 ERP 客户端。
|
||||
- 把不含私钥的公钥证书安装到 ERP Windows 用户或本机的 `TrustedPeople`。
|
||||
- 客户端验证按 40 位 SHA-1 certificate thumbprint 精确查找证书,再用证书内 RSA 公钥验证 SHA-256 签名。thumbprint 只用于定位,内容签名算法仍是 RSA-SHA256。
|
||||
- CNG-only 私钥不适用于当前 `.NET Framework 4.0` 签发脚本;应使用可暴露 `RSACryptoServiceProvider` 的证书。
|
||||
|
||||
## 2. 准备运行时配置、客户画像和三份验收输入
|
||||
|
||||
- 已完成所有工作流开关、字段映射、金额/税额模式、容差、`customerProfilePath` 和验收清单路径设置的最终 `schemaVersion=1.1` `business-adapters.json`。先生成该配置,再签发清单;配置中的清单路径可以尚不存在。
|
||||
- 经内置管理员在当前 ERP 连接执行 `lserp-cli adapters revalidate-profile --input <画像>` 后,`criticalCatalogContractMatches=true`,且不存在数据库身份、兼容级别或关键目录漂移的 1.2 客户只读画像。本次签发工作流必须显示 `workflowActivation.<purchase|leave>.approved=true`、`openBlockerCount=0`;已真实完成并复核的阻断项状态改为 `resolved`,同时写入严格 `resolution` 对象:`evidenceArtifact`、对应最终字段映射或写集成文件的原始字节 `evidenceSha256`、`approvedBy` 和 UTC `approvedAtUtc`。仍开放的项必须是 `resolution=null`;不得删除、替换或新增固定阻断码。采购还必须把目标选择改为 `selected_and_activation_approved/activationAllowed=true`。另一工作流可以仍保持 `open`。部署 Agent 支撑对象导致的表/视图/过程/触发器总数变化,以及画像原先为 `agentWorkflowObjectsPresent=false` 后变为已部署,是签发脚本唯一允许的非关键漂移;其他漂移全部拒绝。脚本会再次调用最终 CLI 在线复核并逐项确认解决哈希与清单即将签名的精确制品哈希一致;只保存一份旧命令输出不能代替该步骤。
|
||||
- 禁止手工编辑上述 `resolved/resolution`。三份证据齐备后,在同一客户 ERP 管理员会话运行 `lserp-cli adapters prepare-profile-activation <purchase|leave> --input <开放画像> --field-mapping <最终映射> --read-evidence <只读证据> --write-evidence <写集成证据> --runtime-sha256 <最终配置哈希> --source-commit <提交> --package-sha256 <商用包哈希> --output <新画像>`。该命令会重新验证当前低代码字段、证据范围和在线关键目录,并只新建一份 `registrationReady=false` 的未签名候选;随后把这份新画像传给本签发流程。
|
||||
- 已人工复核的最终字段映射 JSON(不是 `export-review` 生成的复核包)。签发脚本会以同一 ERP 账号、账套和子系统再次执行 `adapters validate-fields`,只有 `fieldMapReady=true`、零问题且模块完全一致才接受,并绑定该输入文件原始字节 SHA-256。
|
||||
- `adapters verify-contract` 生成且已离线复核的只读契约证据。
|
||||
- 客户 Windows 验收库中事务回滚、数据库持久幂等、权限复核、原 ERP 保存链、业务审计及重试测试报告。该报告不能再是任意文本:必须使用下述用例模板和严格证据生成器,并绑定当前源码提交、商用 ZIP、账套、子系统和最终运行时配置。
|
||||
|
||||
输入文件不得包含业务隐私、密码、连接串、API Key、原始发票或员工请假原因。
|
||||
|
||||
先生成不会通过验收的用例模板,逐项填入脱敏后的实际观察值;`passed` 在独立复核前必须保持 `false`:
|
||||
|
||||
```powershell
|
||||
.\New-WorkflowWriteCasesTemplate.ps1 `
|
||||
-Workflow purchase `
|
||||
-OutputPath .\purchase.write-cases.json
|
||||
```
|
||||
|
||||
模板会预填每个场景允许的 `resultCode` 和必要的 `issueCode`,这些是验收期望,不代表测试已通过;必须用实际 CLI/桥响应逐项核对,禁止为了通过验证而改写观察结果。
|
||||
|
||||
当前写集成报告使用严格 `schemaVersion=1.6`。每个用例必须绑定同一份 `schemaVersion=1.2` 签名短时 UAT 授权的原始文件/内容/授权 ID 哈希、固定用例唯一令牌哈希,以及实际执行桥调用的 `lserp-agent-cli.exe` 版本、SHA-256 和签发者;跨用例复用令牌、运行 CLI 漂移或用管理员 CLI 冒充运行 CLI都会被拒绝。报告还必须绑定同一 ERP PID 返回的上下文、计划/执行共用关联 ID、账套/子系统/用户编号哈希/用户名哈希/数据库作用域/管理员状态,以及实际命令名、版本、模块、风险和调度器固定十分钟计划有效期;汇总器再把全部用例反向绑定到报告声明的模块、账套、子系统、精确 ERP 登录身份和 UAT 授权中的运行 CLI。采购固定 13 项:唯一匹配提交、歧义阻断、累计超额阻断、命令权限阻断、数据库权限复核阻断、目标币种字段未配置、币种换算未审批、组织/部门/采购员精确范围未授权、确认后来源变化、事务回滚、幂等重放、幂等冲突和审计关联。请假固定 19 项:单日自然语言唯一解析、多日区间员工日历解析、解析凭证绕过阻断、歧义假别、歧义流程类别、缺少上午/下午/全天、带 `Z`/偏移的本地时间拒绝、越权员工、命令权限阻断、数据库权限复核阻断、草稿创建、第二次独立提交确认、时间冲突、预览后流程类别失效、其他运行时变化(含日历工时快照改变)、事务回滚、幂等重放、幂等冲突和审计关联。
|
||||
|
||||
每项 `resultCode` 必须等于真实 CLI/数据库稳定码;计划阶段阻断取桥响应中的 `plan.outcomeCode`,执行阶段取 `result.code`,不能填用例名或任意合法字符串。采购歧义/超额固定为 `purchase_match_invalid`;请假解析阻断固定为 `leave_resolution_invalid`,重叠阻断固定为 `leave_request_invalid`。四类自然语言解析/越权阻断还必须填写精确 `issueCode`。所有确认后阻断、幂等重放和幂等冲突都必须带计划指纹、幂等键哈希和 `nativeConfirmationObserved=true`;确认前阻断不得伪称已确认。
|
||||
|
||||
验证器还会做跨用例关联:提交与幂等重放必须指向相同规范化业务输入、记录、事务、审计和幂等键,但桥在提交成功后会消费原计划,因此重放必须由新的计划发起并使用不同计划指纹;幂等冲突必须复用同一键但使用不同业务输入/计划。采购和请假 create 的输入指纹只排除顶层精确 `resolutionProof`,允许新的 resolve 凭证重放同一业务;其他字段、其他命令和嵌套同名字段不排除,新计划仍必须携带有效凭证。重放和冲突都必须至少包含“新计划 + 执行结果/失败”两条命令审计事件。审计关联必须指向原提交及原计划;请假的独立提交必须沿用草稿记录但使用不同计划、事务、审计和幂等键。采购成功、重放和审计关联三项还必须用同一个 `sourceDocumentSetSha256` 和同一有序 `sourceDocumentPreprocessContracts` 证明附件集合与处理实现同时进入命令输入指纹、写过程 payload 和同一业务审计链;该数组必须精确包含 `pdfium_minimax_pages_v1`。报告中不得保存原始发票内容。所有业务记录号、附件集合指纹、事务证据 ID、审计 ID 和幂等键只写 SHA-256,不写原值。
|
||||
|
||||
`sourceDocumentSetSha256` 的复核算法固定为:取本次受信任附件回执与 `p_agent_business_source_document` 中的每个小写 `source_sha256`,去重后按 Ordinal 升序排列,以单个 `\n` 连接(末尾不加换行),再对 UTF-8 无 BOM 字节计算小写 SHA-256。`sourceDocumentAuditCount` 等于同一 `businessAuditId` 下参与该集合的去重审计行数。每一行还必须有 64 位小写 `extraction_sha256` 和非空 `preprocess_contract`,并与命令输入中同一 `source_sha256` 对应的 `extractionSha256/preprocessContract` 完全一致;三者已经进入解析凭证和 `inputFingerprint`。`sourceDocumentWritePayloadBound=true` 只有在 DBA 观察到两种摘要和处理契约均进入固定 XML v3 payload 与同一业务审计事务时才能填写。这样验收人员可以证明文件集合、Agent 实际看到的提取版本和具体受信任处理链均未被替换,而无需把文件名、路径或原始发票内容写进报告。
|
||||
|
||||
### 签发一次性客户 UAT 授权
|
||||
|
||||
写命令在生产配置中始终默认关闭。只有客户可恢复、明确非生产的 UAT 库可以由提升权限的 Windows PowerShell 5.1 运行 `New-WorkflowUatAuthorization.ps1`,将客户、环境、ERP 用户、运行配置、客户画像、发布策略、最终 ZIP、签名 ERP/CLI 及固定用例绑定到最长 24 小时的 RSA-SHA256 授权。命令必须显式传入 `-DatabaseBackupVerified`、`-RestoreProcedureVerified`、`-NonProductionEnvironmentVerified`、`-NativeConfirmationVerified` 和 `-TransactionAuditVerified`;任一安全事实未验证都不得签发。生成器同时创建仅当前提升用户与 LocalSystem 可访问、带高完整性标签并由 DPAPI CurrentUser 加密的令牌库。
|
||||
|
||||
将授权文件路径及其原始字节 SHA-256 通过 `LSERP_WORKFLOW_UAT_AUTHORIZATION`、`LSERP_WORKFLOW_UAT_AUTHORIZATION_SHA256` 注入 ERP,再重启 ERP。UAT 模式会隐藏通用写能力,只接受授权中固定的工作流、命令和用例。令牌库只供现场采集器读取,严禁放进商用 ZIP、总验收目录、日志、聊天机器人或模型上下文;授权文件、绑定最终包内受限运行时 CLI 身份及精确 ERP PID/数据库作用域/用户/账套/子系统并逐模块验证载荷与原生执行关系的 1.5 只读会话预检,以及绑定该预检 SHA-256 和同一运行时 CLI 的 1.1 现场报告,都必须作为独立原始制品进入总包。
|
||||
|
||||
示例(授权与令牌库都必须是尚不存在的新文件):
|
||||
|
||||
```powershell
|
||||
.\New-WorkflowUatAuthorization.ps1 `
|
||||
-Workflow both `
|
||||
-PurchaseModuleCode PURCHASE -PurchaseAdapterId lserp.purchase.customer-a -PurchaseAdapterVersion 1.0.0 `
|
||||
-LeaveModuleCode LEAVE -LeaveAdapterId lserp.leave.customer-a -LeaveAdapterVersion 1.0.0 `
|
||||
-AuthorizationId CUSTOMER-A-UAT-20260813-01 `
|
||||
-CustomerId CUSTOMER-A -EnvironmentId CUSTOMER-A-UAT-01 `
|
||||
-AccountBook ACCOUNT-1 -SubSystemId SUB-1 `
|
||||
-ErpUserId 1 -ErpUserName 管理员 `
|
||||
-DatabaseScopeFingerprint <当前ERP返回的64位数据库作用域指纹> `
|
||||
-RuntimeConfigurationFile .\business-adapters.json `
|
||||
-CustomerProfileFile .\lserp-ai.readonly-map.json `
|
||||
-RolloutPolicyFile .\command-rollout.json `
|
||||
-CommercialPackageFile .\Lserp-AgentPet-win-x64.zip `
|
||||
-SourceCommit <40位提交号> -ExpectedPackageSha256 <最终ZIP的64位哈希> `
|
||||
-ErpExecutablePath D:\Acceptance\legacy-erp-build\Runtime\Ls_ERP.exe `
|
||||
-RuntimeCliPath D:\Acceptance\agent-pet\Host\lserp-agent-cli.exe `
|
||||
-ExpectedRuntimeCliVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-ExpectedErpSignerThumbprint <ERP的40位签发者指纹> `
|
||||
-ExpectedCliSignerThumbprint <CLI的40位签发者指纹> `
|
||||
-ExpectedRuntimeCliSignerThumbprint <运行CLI的40位签发者指纹> `
|
||||
-ApprovedBy QA-APPROVER-1 `
|
||||
-DatabaseBackupVerified -RestoreProcedureVerified `
|
||||
-NonProductionEnvironmentVerified -NativeConfirmationVerified `
|
||||
-TransactionAuditVerified `
|
||||
-CertificateThumbprint <UAT授权签名证书指纹> `
|
||||
-OutputPath .\workflow-uat-authorization.json `
|
||||
-TokenVaultPath C:\ProgramData\Langsu\Acceptance\workflow-uat-token-vault.json `
|
||||
-ValidHours 8
|
||||
```
|
||||
|
||||
### 建立可断点续跑的现场 UAT 活动
|
||||
|
||||
授权签发、ERP 重启并加载同一授权后,不要编写“循环执行 32 个用例”的批量脚本。使用 `New-WorkflowWriteUatCampaign.ps1` 从已签名授权生成固定活动清单。脚本只创建当前提升用户与 LocalSystem 可访问、带高完整性标签的受限目录,固化授权/CLI 身份、13+19 项顺序、命令、计划或执行模式、依赖、幂等关系和 DBA 人工阶段;不会连接桥、读取令牌明文或写数据库,也不会创建业务输入、幂等键或令牌文件。活动目录不是最终验收制品,不能放进客户总验收目录。
|
||||
|
||||
商用包中的 `workflow-write-uat-case-catalog.v1.json` 是这 32 项的版本化现场目录,给每项固定角色、夹具类别、前置条件、操作步骤、DBA 只读核对、清理、重试策略及预期结果。它不含凭据、业务标识、SQL 或可执行指令。生成器和检查器都内置并复核其精确 SHA-256,活动清单还会再次记录该哈希;不要编辑、另存或“按客户习惯”改写此文件。默认读取脚本同目录的文件,如需从受控制品目录显式指定,只能通过 `-CaseCatalogFile` 指向同名且哈希完全一致的副本。
|
||||
|
||||
```powershell
|
||||
.\New-WorkflowWriteUatCampaign.ps1 `
|
||||
-CampaignId CUSTOMER-A-UAT-20260813-01 `
|
||||
-UatAuthorizationFile .\workflow-uat-authorization.json `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-RuntimeCliPath D:\Acceptance\agent-pet\Host\lserp-agent-cli.exe `
|
||||
-ExpectedUatAuthorizationSha256 <授权文件的64位原始字节哈希> `
|
||||
-ExpectedVerifierCliSha256 <LEGACY-BUILD-EVIDENCE中的64位CLI哈希> `
|
||||
-ExpectedRuntimeCliVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-ExpectedRuntimeCliSha256 <最终ZIP清单中的运行CLI哈希> `
|
||||
-ExpectedVerifierSignerThumbprint <管理员CLI的40位签发者指纹> `
|
||||
-ExpectedRuntimeSignerThumbprint <运行CLI的40位签发者指纹> `
|
||||
-OutputRoot C:\ProgramData\Langsu\Acceptance\Campaigns
|
||||
```
|
||||
|
||||
生成器不会替换已有活动目录。把每个场景经客户脱敏、单独复核的输入放到清单指定的 `private-input/<caseCode>.json`;不要在活动目录保存原始发票、请假原因、令牌库、明文幂等键或 CLI 原始响应。令牌库必须继续留在活动目录之外的受控路径。
|
||||
|
||||
每次开始或中断后恢复前先运行只读检查器:
|
||||
|
||||
```powershell
|
||||
.\Test-WorkflowWriteUatCampaign.ps1 `
|
||||
-CampaignFile C:\ProgramData\Langsu\Acceptance\Campaigns\CUSTOMER-A-UAT-20260813-01\campaign.json `
|
||||
-UatAuthorizationFile .\workflow-uat-authorization.json `
|
||||
-UatTokenVaultPath C:\ProgramData\Langsu\Acceptance\workflow-uat-token-vault.json `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-RuntimeCliPath D:\Acceptance\agent-pet\Host\lserp-agent-cli.exe `
|
||||
-ExpectedUatAuthorizationSha256 <授权文件哈希> `
|
||||
-ExpectedVerifierCliSha256 <管理员CLI哈希> `
|
||||
-ExpectedRuntimeCliVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-ExpectedRuntimeCliSha256 <运行CLI哈希> `
|
||||
-ExpectedVerifierSignerThumbprint <管理员CLI签发者指纹> `
|
||||
-ExpectedRuntimeSignerThumbprint <运行CLI签发者指纹> `
|
||||
-ErpProcessId 1234
|
||||
```
|
||||
|
||||
检查器用管理员 `lserp-cli.exe` 验证签名授权和脱敏证据,只用受限 `lserp-agent-cli.exe` 执行 `version` 与桥 `health`;没有 execute 路由,也不解密令牌。它从已经存在且通过严格语义验证的脱敏证据推导断点,输出唯一 `nextCase`、`inputReady` 以及该项唯一的 `operatorGuide`,其中只包含当前项的角色、准备、操作、只读核对和清理说明,绝不展开整份目录或自动运行采集器。若目录哈希、提交证据与同次派生的审计证据、目录文件集、令牌库位置、授权/双 CLI 身份/ERP PID 或依赖关系不成立,恢复会失败关闭。完整工作流会额外离线复验 13/19 项覆盖率、运行 CLI 一致性和跨用例关系。
|
||||
|
||||
### 从 CLI 响应生成脱敏用例
|
||||
|
||||
客户 Windows 验收机优先使用随包提供的 `Invoke-WorkflowWriteCaseCapture.ps1`,不要手工把多层 CLI JSON 复制进观察清单。该脚本必须由提升权限的 Windows PowerShell 5.1(`powershell.exe`)运行,以使用受控 NTFS ACL、DPAPI、高完整性标签和 Authenticode;不会在 PowerShell 7、macOS 或 Linux 上降级执行。脚本锁定并离线验证签名 UAT 授权、受限令牌库和最终 CLI,复核它们的预期 SHA-256/签发者以及 ERP 已加载的同一授权。它从签名授权中取得并验证原始 ERP PID,以及数据库作用域指纹、用户编号、用户名、账套、子系统和管理员布尔值六项预期范围,然后把 PID 和六项范围作为每次受限 CLI `health/context/plan/execute` 的显式参数;CLI 自身仍会在目标命令前后复核实际 ERP 上下文。UAT 令牌只通过关闭的标准输入传给 `--uat-token-stdin`,幂等键只通过同一次关闭的标准输入传给 `--idempotency-key-stdin`,两者都不进入命令行或输出。对采购/请假创建场景,脚本会在受限临时目录自动执行 `resolve -> create`,只把服务器返回的短期 `resolutionProof` 用于后续计划,不要求人工准备或导出该凭证。它绑定同一关联 ID,分别保存源输入、实际创建输入、上下文、准备计划、目标计划和可选执行响应,再离线投影;成功或失败后都会清理受限原始目录,清理失败时已发布输出也会撤回。
|
||||
|
||||
`purchase_runtime_recheck_blocked`、`leave_stale_flow_type_blocked` 和 `leave_runtime_recheck_blocked` 必须在生成可执行计划后由已授权 DBA/配置人员改变来源或配置,再验证执行阶段确实失败关闭。运行这三项时必须同时传入 `-Execute -PauseAfterPlanForOperatorStaging`,采集器会在计划后暂停,要求输入精确的 `STAGED:<caseCode>`,随后再次复核 CLI、授权文件和令牌库哈希才允许继续执行。该模式禁止 `-NonInteractive`,也不能用于其他场景。采集器不会替 DBA 修改配置或数据。
|
||||
|
||||
执行完成后脚本会要求验收人员输入 DBA 只读查询/现场确认得到的业务变更数、原生确认观察、命令审计数、来源 payload 绑定和来源审计数。`-NonInteractive` 模式不会猜测这些值,缺少任一观察参数即失败。成功创建场景可传 `-CorrelatedAuditOutputPath`,用同一次计划/执行响应额外生成 `purchase_audit_correlated` 或 `leave_audit_correlated`,其业务变更数固定为零,因此不会为了取得审计证据再次写业务数据。派生审计证据必须绑定授权中自己的唯一审计用例令牌哈希,同时保留实际执行用例码;投影器只允许“采购提交→采购审计”与“请假草稿创建→请假审计”两组固定映射,不能借其他用例响应派生。
|
||||
|
||||
示例(观察值省略时,脚本会在执行后逐项提示):
|
||||
|
||||
```powershell
|
||||
$key = Read-Host '本次稳定业务幂等键' -AsSecureString
|
||||
.\Invoke-WorkflowWriteCaseCapture.ps1 `
|
||||
-CaseCode purchase_unique_match_commit `
|
||||
-CommandName purchase.invoice.create `
|
||||
-CommandInputFile .\restricted-input\purchase-create.json `
|
||||
-OutputPath .\cases\purchase_unique_match_commit.json `
|
||||
-CorrelatedAuditOutputPath .\cases\purchase_audit_correlated.json `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-RuntimeCliPath D:\Acceptance\agent-pet\Host\lserp-agent-cli.exe `
|
||||
-UatAuthorizationFile .\workflow-uat-authorization.json `
|
||||
-UatTokenVaultPath C:\ProgramData\Langsu\Acceptance\workflow-uat-token-vault.json `
|
||||
-ExpectedUatAuthorizationSha256 <授权文件的64位原始字节哈希> `
|
||||
-ExpectedVerifierCliSha256 <LEGACY-BUILD-EVIDENCE中的64位哈希> `
|
||||
-ExpectedRuntimeCliVersion @LSERP_PACKAGE_VERSION@ `
|
||||
-ExpectedRuntimeCliSha256 <最终ZIP清单中的运行CLI哈希> `
|
||||
-ExpectedVerifierSignerThumbprint <管理员CLI的40位证书指纹> `
|
||||
-ExpectedRuntimeSignerThumbprint <运行CLI的40位证书指纹> `
|
||||
-ErpProcessId 1234 `
|
||||
-Execute `
|
||||
-IdempotencyKey $key
|
||||
```
|
||||
|
||||
计划阶段阻断用例省略 `-Execute` 和幂等键。无人值守调用可改用只允许一行的受限 `-IdempotencyKeyFile`,并显式传入 `-BusinessMutationCount`、`-NativeConfirmationObserved`、`-AuditEventCount`、`-SourceDocumentWritePayloadBound`、`-SourceDocumentAuditCount` 与 `-NonInteractive`;调用者提供的幂等键文件不由脚本删除,验收流程必须自行安全销毁。
|
||||
|
||||
底层文件索引使用严格 `schemaVersion=1.3`,除命令、上下文、计划/执行和 DBA 观察字段外,还固定包含 UAT 授权原始文件/内容/授权 ID 哈希、授权签发/失效时间、实际执行用例码、该用例令牌哈希及运行 CLI 的版本/SHA-256/签发者。文件引用只允许同目录、不重复的安全 ASCII `.json` 基本名;绝对路径、目录穿越和链接均被拒绝。上下文响应必须成功;至少要有一份计划/执行 CLI 响应;有执行响应时必须同时带成功且可执行的计划响应,而且上下文、准备计划、目标计划与执行必须显式使用同一个安全 `--correlation-id`。投影器还会拒绝观察时间不在授权窗口、执行用例与索引不一致、授权哈希漂移或运行 CLI 身份不合法。
|
||||
|
||||
仅在排查旧验收材料时,才手工创建内嵌式原始观察清单。除授权三重哈希和唯一用例令牌哈希外,其字段固定为:`runtimeCliVersion`、`runtimeCliSha256`、`runtimeCliSignerThumbprint`、`caseCode`、`commandName`、原始 `commandInput`、可空的 `planCliResponse`、可空的 `executeCliResponse`、可空的原始 `idempotencyKey`、`businessMutationCount`、`nativeConfirmationObserved`、`auditEventCount`、`sourceDocumentWritePayloadBound`、`sourceDocumentAuditCount`、`observedAtUtc`。计数和两个来源布尔值同样必须来自 DBA 查询/现场确认,不能由模型猜测。
|
||||
|
||||
运行:
|
||||
|
||||
```powershell
|
||||
lserp-cli adapters project-write-observation `
|
||||
--input .\restricted\purchase-commit.raw.json `
|
||||
--output .\cases\purchase_unique_match_commit.json
|
||||
```
|
||||
|
||||
投影器会严格拒绝未知/重复 JSON 字段、注释、链接文件、错配的命令输入指纹、错误的固定结果码、缺失的精确 `issueCode`、不成立的确认/事务/幂等/审计语义以及已有输出文件。采购提交、幂等重放和审计关联三项还必须精确调用 `purchase.invoice.create 1.4`,输入通过完整创建 Schema(发票日期、头金额、至少一条完整明细和来源附件均不可省略),至少包含一份 `pdfium_minimax_pages_v1` 电子 PDF,`resolutionProof` 符合完整 `rp1` 结构,业务结果明确 `success=true`,且计划的 `sourceDocumentCount/sourceDocumentSetSha256` 与命令输入中去重排序后的附件集合一致;仅填写成功码不能通过。离线投影不能取得 ERP 进程内 HMAC 密钥,因此只校验凭证结构;凭证签名、时效和会话/草稿绑定由同一受信任 ERP 进程在计划与执行时实际复核。`planFingerprintSha256` 固定为“小写 `planId` 的 UTF-8 无 BOM 字节 SHA-256”;业务 ID 和幂等键分别独立哈希。采购来源集合按上一段算法生成。原始观察清单不得进入最终 ZIP,也不得发送给模型或聊天机器人。
|
||||
|
||||
若原始响应已经分别保存在同一受限目录,也可直接运行:
|
||||
|
||||
```powershell
|
||||
lserp-cli adapters project-write-observation-files `
|
||||
--input .\restricted\purchase-commit.file-index.json `
|
||||
--output .\cases\purchase_unique_match_commit.json
|
||||
```
|
||||
|
||||
全部单用例完成后,在同一目录创建不含路径的索引,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"workflow": "purchase",
|
||||
"caseFiles": [
|
||||
"purchase_unique_match_commit.json",
|
||||
"purchase_ambiguous_match_blocked.json",
|
||||
"purchase_overallocation_blocked.json",
|
||||
"purchase_permission_denied.json",
|
||||
"purchase_database_permission_recheck_denied.json",
|
||||
"purchase_currency_field_missing_blocked.json",
|
||||
"purchase_currency_crosswalk_unapproved_blocked.json",
|
||||
"purchase_row_scope_denied.json",
|
||||
"purchase_runtime_recheck_blocked.json",
|
||||
"purchase_transaction_rollback.json",
|
||||
"purchase_idempotency_replay.json",
|
||||
"purchase_idempotency_conflict.json",
|
||||
"purchase_audit_correlated.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
索引必须完整列出采购 13 个或请假 19 个文件。文件名只允许安全 ASCII 基本名,禁止绝对路径、目录穿越、链接和重复项。运行:
|
||||
|
||||
```powershell
|
||||
lserp-cli adapters assemble-write-observations `
|
||||
--input .\cases\purchase-index.json `
|
||||
--output .\cases\purchase-cases.json
|
||||
```
|
||||
|
||||
汇总器会按固定用例顺序输出数组,并在发布前再次检查完整覆盖以及提交、重放、冲突、审计、来源附件和请假独立提交之间的哈希关系。把该数组作为 `New-WorkflowWriteIntegrationEvidence.ps1 -CasesFile` 的输入,不再手工复制 JSON。
|
||||
|
||||
复核用例后生成自哈希报告。这里的 CLI 必须是最终 Windows 旧 ERP 构建目录中已签名且受 `LEGACY-BUILD-EVIDENCE.json` 保护的 CLI:
|
||||
|
||||
```powershell
|
||||
.\New-WorkflowWriteIntegrationEvidence.ps1 `
|
||||
-Workflow purchase `
|
||||
-ModuleCode PURCHASE `
|
||||
-AccountBook ACCOUNT-1 `
|
||||
-SubSystemId SUB-1 `
|
||||
-SourceCommit <40位提交号> `
|
||||
-PackageSha256 <商用ZIP的SHA-256> `
|
||||
-RuntimeConfigurationFile .\business-adapters.json `
|
||||
-RolloutCustomerId CUSTOMER-A `
|
||||
-EnvironmentId CUSTOMER-A-UAT-01 `
|
||||
-TestedBy QA-ADMIN-1 `
|
||||
-CasesFile .\purchase.write-cases.json `
|
||||
-UatAuthorizationFile .\workflow-uat-authorization.json `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-OutputPath .\purchase-windows-test-report.json
|
||||
```
|
||||
|
||||
生成器先写临时文件,再调用 CLI 严格验证;遗漏、重复、未知、失败、结果码或问题码不匹配、来源附件未贯通、跨提交、跨包、跨账套或证据字段不足时不会发布最终报告。
|
||||
|
||||
## 3. 签发清单
|
||||
|
||||
在持有证书私钥的受控 Windows 签发机执行:
|
||||
|
||||
```powershell
|
||||
$erpPassword = Read-Host 'ERP password' -AsSecureString
|
||||
.\New-WorkflowAcceptanceEvidence.ps1 `
|
||||
-Workflow purchase `
|
||||
-ModuleCode PURCHASE `
|
||||
-AccountBook ACCOUNT-1 `
|
||||
-SubSystemId SUB-1 `
|
||||
-AdapterId lserp.purchase.customer-a `
|
||||
-AdapterVersion 1.0.0 `
|
||||
-EvidenceId acc-purchase-20260811 `
|
||||
-RuntimeConfigurationFile .\business-adapters.json `
|
||||
-CustomerProfileFile .\lserp-ai.readonly-map.json `
|
||||
-FieldMappingEvidence .\purchase-mapping.json `
|
||||
-ReadContractEvidence .\purchase-read-evidence.json `
|
||||
-WriteIntegrationEvidence .\purchase-windows-test-report.json `
|
||||
-VerifierCliPath D:\Acceptance\legacy-erp-build\Runtime\lserp-cli.exe `
|
||||
-ErpUser 1 `
|
||||
-ErpPassword $erpPassword `
|
||||
-ExpectedSourceCommit <40位提交号> `
|
||||
-ExpectedPackageSha256 <商用ZIP的SHA-256> `
|
||||
-ValidatedBy QA-ADMIN-1 `
|
||||
-CertificateThumbprint 00112233445566778899AABBCCDDEEFF00112233 `
|
||||
-OutputPath .\purchase.signed-evidence.json
|
||||
```
|
||||
|
||||
脚本先以只读共享锁锁定最终 CLI、运行配置、画像、字段映射及两份集成证据;六个输入和输出必须是互不重复的普通文件,验证与签名期间不能被写入、删除或同名替换。随后用最终 CLI 和同一 ERP 身份在线复核客户画像,并拒绝本次工作流任何未关闭阻断项;画像为采购/请假声明的模块编号还必须与 `-ModuleCode` 逐字一致。再实时验证最终字段映射,离线验证只读契约证据的 `verified=true` 及工作流/模块/账套/子系统,最后验证写集成报告的完整用例、运行时配置、源码提交和商用包哈希。三个业务证据输入在验证前后还会重新计算 SHA-256,变化时不签;ERP 密码只以 `SecureString` 接收并通过 stdin 交给 CLI,不进入命令行、环境变量、签名内容或日志。最后使用 `CreateNew` 签发,不会覆盖已有清单。输出的 `evidenceSha256` 和 `validatedAtUtc` 必须原样写入 V2 就绪行。签发后只要 `business-adapters.json` 或客户画像任一字节变化(包括工作流开关、字段、金额模式、容差、路径、阻断状态或格式),启动注册门禁都会拒绝旧清单,必须重新复核并签发。运行时每次就绪检查还会重新打开 `LSERP_BUSINESS_ADAPTER_CONFIG` 指向的普通文件并计算实际 SHA-256;配置修改或替换返回 `runtime_configuration_changed`,文件丢失、权限异常、超限或链接属性返回 `runtime_configuration_unavailable`,两者都会在进入固定数据库写过程前阻断写入。
|
||||
|
||||
## 4. 离线验证
|
||||
|
||||
把公钥证书安装到验收机 `TrustedPeople` 后执行:
|
||||
|
||||
```text
|
||||
lserp-cli adapters verify-acceptance-evidence --input purchase.signed-evidence.json
|
||||
```
|
||||
|
||||
该命令不连接 ERP 或数据库,只验证严格 JSON、内容哈希、证书信任、RSA 签名和有效期,并输出清单绑定的 `runtimeConfigurationSha256` 与 `customerProfileSha256` 供部署复核。输出的 `registrationReady=false` 是刻意设计:只有清单通过并与 ERP 启动时实际读取的配置及画像原始字节哈希、当前 V3 就绪结果及其 V2 验收证据行、在线系统目录、低代码字段及运行时过程健康检查全部一致,ERP 进程才会注册写命令。
|
||||
|
||||
## 5. 写入 V2 验收证据并启用 V3 就绪核对
|
||||
|
||||
部署 `SqlServer/002_workflow_adapter_contract.sql`、`003_record_workflow_acceptance.sql` 和只读的 `006_workflow_readiness_v3.sql` 后,通过客户变更单调用参数化过程 `p_lserp_agent_record_workflow_acceptance_v2`。必须使用签发脚本输出的精确范围、`evidence_id`、`evidence_sha256`、`validated_by` 和 `validated_at_utc`;六项验证字段全部来自已签名清单。不要把记录过程授权给 ERP 日常运行账号,只允许客户 DBA 或发布流水线调用。ERP 日常账号只调用 V3 就绪查询;该查询会按数据库兼容级别选定实际读写过程,核对完整有序参数签名,并在过程修改时间晚于验收时间时返回零行。
|
||||
|
||||
旧 `p_agent_workflow_adapter_evidence` 和旧 readiness 过程不会自动迁移,也不能启用当前客户端。V2 主键包含工作流、模块、账套和子系统,禁止复制其他账套的证据行。
|
||||
|
||||
## 6. 启用配置
|
||||
|
||||
在 `business-adapters.json` 对相应工作流设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.1",
|
||||
"customerProfilePath": "customer-profiles/lserp-ai.readonly-map.json",
|
||||
"purchase": {
|
||||
"enabled": true,
|
||||
"acceptanceEvidencePath": "acceptance/purchase.signed-evidence.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
相对路径按适配器配置文件所在目录解析。清单必须是 256 KB 内的普通 UTF-8 文件,链接文件、重复属性、JSON 注释、多根值、未知字段、错误哈希或过期证据都会失败关闭。
|
||||
|
||||
启动 ERP 后,以管理员身份查看 `adapters.status` 和 `capabilities.list`。只有全部门禁通过时才应出现 `purchase.invoice.create` 或请假三个命令。
|
||||
|
||||
采购和请假都签发完成后,不要把两份清单孤立交付。按 `CUSTOMER_ACCEPTANCE.md` 收集 23 个原始文件:采购与请假各用一份只授权自身工作流的 UAT 文件、各自子系统的 1.5 只读预检和 1.1 现场交接,诊断另用管理员子系统预检与交接。向 `New-CustomerAcceptanceBundle.ps1` 分别传三组会话文件与子系统、两份 UAT 文件、管理员 `-VerifierCliPath`、最终 ZIP 中的受限 `-RuntimeCliPath` 及 `-ExpectedRuntimeCliVersion`,生成 `schemaVersion=1.8` 客户总签章。最终验证器会拒绝运行时/管理员 CLI 角色混用、跨子系统交换、合并授权或角色混用;令牌库绝不能进入目录。管理员验证 CLI 必须与旧 ERP 构建证据一致,受限运行时 CLI 必须与最终 ZIP 清单、版本、签名完全一致。
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schemaVersion": "1.1",
|
||||
"customerProfilePath": "customer-profiles/lserp-ai.readonly-map.json",
|
||||
"purchase": {
|
||||
"enabled": false,
|
||||
"acceptanceEvidencePath": "acceptance/purchase.signed-evidence.json",
|
||||
"fields": {
|
||||
"moduleCode": "acc_1007",
|
||||
"supplierCode": "acc_mphhscm_Providerid",
|
||||
"invoiceNumber": "acc_mphhscm_invoice",
|
||||
"invoiceDate": "acc_mphhscm_invoiceDate",
|
||||
"currencyCode": "acc_mphhscm_currency",
|
||||
"materialCode": "acc_lphhscm_productid",
|
||||
"unit": "acc_lphhscm_Productunitname",
|
||||
"quantity": "acc_lphhscm_amount",
|
||||
"unitPrice": "acc_lphhscm_price",
|
||||
"taxRate": "acc_lphhscm_taxRate",
|
||||
"exchangeRate": "acc_lphhscm_exchangeRate",
|
||||
"lineAmount": "acc_lphhscm_summoney",
|
||||
"sourceOrderId": "acc_lphhscm_sourcebillid",
|
||||
"sourceLineId": "acc_lphhscm_ScmPoid"
|
||||
},
|
||||
"matchOptions": {
|
||||
"quantityTolerance": 0.0001,
|
||||
"unitPriceAbsoluteTolerance": 0.01,
|
||||
"unitPriceRelativeTolerance": 0.0001,
|
||||
"taxRateTolerance": 0.0001,
|
||||
"lineAmountTolerance": 0.02,
|
||||
"headerAmountTolerance": 0.05,
|
||||
"currencyScale": 2,
|
||||
"lineAmountMode": 2
|
||||
}
|
||||
},
|
||||
"leave": {
|
||||
"enabled": false,
|
||||
"acceptanceEvidencePath": "acceptance/leave.signed-evidence.json",
|
||||
"fields": {
|
||||
"moduleCode": "hr_4011",
|
||||
"employeeId": "hr_ela_empid",
|
||||
"leaveTypeCode": "hr_ela_type",
|
||||
"flowTypeCode": "hr_ela_billtype",
|
||||
"startLocal": "hr_ela_starttime",
|
||||
"endLocal": "hr_ela_finishtime",
|
||||
"requestedHours": "hr_ela_totals",
|
||||
"reason": "hr_ela_Leavebak"
|
||||
},
|
||||
"validationOptions": {
|
||||
"allowPastStart": false,
|
||||
"maximumCalendarDays": 31,
|
||||
"minimumReasonLength": 2,
|
||||
"hoursTolerance": 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schemaVersion": "1.1",
|
||||
"customerId": "CUSTOMER-001",
|
||||
"databaseScopeFingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"defaultAction": "deny",
|
||||
"rules": [
|
||||
{
|
||||
"command": "module.search",
|
||||
"commandVersion": "1.0",
|
||||
"requiredPermission": "module.view",
|
||||
"accountBooks": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"请替换为客户账套显示名"
|
||||
]
|
||||
},
|
||||
"subSystemIds": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"audience": "all_authorized",
|
||||
"userIds": []
|
||||
},
|
||||
{
|
||||
"command": "module.parameters",
|
||||
"commandVersion": "1.1",
|
||||
"requiredPermission": "module.view",
|
||||
"accountBooks": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"请替换为客户账套显示名"
|
||||
]
|
||||
},
|
||||
"subSystemIds": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"audience": "all_authorized",
|
||||
"userIds": []
|
||||
},
|
||||
{
|
||||
"command": "module.record.prepare-create",
|
||||
"commandVersion": "1.0",
|
||||
"requiredPermission": "module.view",
|
||||
"accountBooks": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"请替换为客户账套显示名"
|
||||
]
|
||||
},
|
||||
"subSystemIds": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"audience": "all_authorized",
|
||||
"userIds": []
|
||||
},
|
||||
{
|
||||
"command": "module.diagnose",
|
||||
"commandVersion": "1.0",
|
||||
"requiredPermission": "erp.admin",
|
||||
"accountBooks": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"请替换为客户账套显示名"
|
||||
]
|
||||
},
|
||||
"subSystemIds": {
|
||||
"all": false,
|
||||
"values": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"audience": "administrators",
|
||||
"userIds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# lserp_AI 只读复核档案
|
||||
|
||||
`lserp-ai.readonly-map.json` 是基于客户提供的 `lserp_AI` SQL Server 数据库系统目录和低代码配置生成的 1.2 版人工复核材料;它本身不是业务适配器配置。启用采购或请假后,`business-adapters.json` 1.1 必须通过 `customerProfilePath` 引用它,并由每个工作流的签名清单绑定其原始字节 SHA-256;AgentBridge 会在启动、计划和确认执行前自动加载并用固定只读系统目录查询重新比较。目录内两个 `*.candidate.json` 仍只是 `lserp-cli adapters validate-fields` 的候选输入,不是 `LSERP_BUSINESS_ADAPTER_CONFIG` 可加载的运行时结构。画像的 `criticalCatalogContract` 明确列出采购来源/目标表、请假与审批表、低代码配置表以及三条旧保存过程必须存在的列和参数,避免表总数未变时漏掉字段改名或过程签名漂移。
|
||||
|
||||
随包 `../business-adapters.example.json` 已把本画像当前复核出的 `acc_1007` 采购字段、`hr_4011` 请假字段和采购含税口径 `lineAmountMode=2` 投影成严格 1.1 配置候选,但两个工作流固定保持 `enabled=false`。它用于让配置人员从真实低代码映射继续评审,不是可直接上线的默认值:采购币种控件、币种换算、行级范围和写包装尚未验收,请假流程配置与兼容写包装也未验收;不得仅把 `enabled` 改为 `true`。最终配置必须从当前已登录 ERP 重新运行字段门禁、在线画像复核和签名验收后另存到包外 ACL 受控目录。
|
||||
|
||||
档案中的 `purchaseTargetSelection` 固化了三个采购相关模块的用途与选择结论,`purchaseActivationBlockers` 与 `leaveActivationBlockers` 则是机器可读的工作流门禁清单。当前选择 `acc_1007` 只表示它是唯一结构匹配的草稿写入候选,不表示允许激活;在线元数据复核、客户配置和全部验收证据完成前,`activationAllowed` 必须保持 `false`。采购 5 项和请假 4 项的阻断码是代码锁定的精确集合:不能删除、替换或追加。`open` 项必须保持 `resolution: null`;只有真实完成对应证据并经客户复核后才能改为 `resolved`,并填写结构严格的 `resolution={evidenceArtifact,evidenceSha256,approvedBy,approvedAtUtc}`。`purchase_currency_field_not_configured` 必须绑定最终 `field_mapping`;其余采购阻断项和全部请假阻断项必须绑定本工作流的 `write_integration`。其 SHA-256 必须与本次 RSA 签名验收清单中的精确原始制品哈希一致,不是自由填写的文本证明;画像中采购选定模块或请假模块也必须与清单 `moduleCode` 逐字一致。只要当前工作流任一阻断项仍为 `open`,签发脚本和 ERP 启动/计划/执行门禁都会拒绝;采购全部关闭后还必须把选择状态同步改为 `selected_and_activation_approved` 且 `activationAllowed=true`。请假可独立批准,不要求采购同时完成,反之亦然。
|
||||
|
||||
实施人员不应直接编辑这些字段。最终字段映射、只读契约证据和 Windows 写集成报告齐备后,使用已登录客户 ERP 的内置管理员运行 `lserp-cli adapters prepare-profile-activation <purchase|leave>`;命令会绑定当前账套、子系统、模块、源码提交、商用包和运行配置,重新执行只读关键目录复核,并只用新建文件语义输出未签名候选。原画像不会覆盖,数据库不会修改,写命令也不会注册。候选仍须交给 `New-WorkflowAcceptanceEvidence.ps1` 在线复核和 RSA 签名。
|
||||
|
||||
实库核验还发现:服务器引擎虽为 SQL Server 2022,但 `lserp_AI` 数据库兼容级别是 `100`(SQL Server 2008 语义),因此库内不能使用 `OPENJSON`、`ISJSON`、`TRY_CONVERT` 或 `THROW`。AgentBridge 现在会在调用前读取 `sys.databases.compatibility_level`:低于 `130` 时,由受信任 ERP 进程用严格 Newtonsoft JSON 先验解析;普通读取和请假写入只传固定白名单标量,采购明细则由 `XmlWriter` 编码成固定结构、限量且再次校验的 XML 行集,不接受模型原始 XML。采购和请假均已有固定兼容写分派,但缺少严格运行配置、数据库 V2 就绪证据或 TrustedPeople 签名验收时不会注册命令;采购草案内部两道审核开关仍固定为 `0`,当前仍无法写入。兼容级别无法确认时同样失败关闭。
|
||||
|
||||
它明确记录了三类结果:
|
||||
|
||||
- `PUR_5001` 是采购订单主从单据,可作为来源单候选。
|
||||
- `acc_1007` 是唯一满足现有采购适配器“bill + 主从字段”门禁的采购发票候选;但币种字段没有在 `p_systembillInfo/p_systembillDetail` 暴露,且来源订单字段存在历史命名歧义,因此保持不可启用。
|
||||
- `acc_1002` 是“采购发票登记”基础档案菜单,适合导航和只读诊断,不能绕过采购发票主从写入契约。
|
||||
- `hr_4011` 的请假字段已经能与现有 leave 适配器语义对齐。流转类别控件保存的是 `p_systemdlltabflowtype.id`;其旧天数/岗位联动仍指向已经不存在的 3195-3200,而当前流程步骤使用 3629-3634。Agent 当前返回有效候选并在不唯一时追问,不能沿用这段失效配置自动选路。
|
||||
- `lserp-ai.workflow-read.compat100.draft.sql` 是能被兼容级别 100 解析的只读过程审查草案。它以 `SET NOEXEC ON` 开头、内部审核开关固定为 `0`,所以不能部署也不能被调用;正式脚本必须由客户 DBA 另行生成。
|
||||
- `lserp-ai.workflow-write.leave.compat100.draft.sql` 是请假创建/提交的强类型事务写审查草案,同样受 `NOEXEC` 和固定关闭审核开关保护。它派生员工姓名、部门、岗位和天数,调用原 `p_BaseSave70/p_baseApply`,并要求幂等、审计、事务证据和 Outbox 同事务完成;未签署前不会注册成可用写命令。
|
||||
|
||||
实库过程签名确认 `p_BaseSave70` 的保存确认参数当前默认值为 `0`;草案仍显式传入固定 `@comfirmFlag = 0`,从而把“只创建草稿、不隐式确认”的语义绑定在客户包装合同中,不依赖未来可能漂移的过程默认值。
|
||||
- `lserp-ai.workflow-write.purchase.compat100.draft.sql` 是采购发票创建的固定标量加 XML 行集审查草案。它同时固定关闭 DBA 审核和采购行级范围审核,复核目标币种字段、签字币种换算、采购订单快照和可用量后,才会调用 WinForm 同一条 `P_BillSavePr70` 保存链生成未提交草稿;ERP 网关已有精确分派,但只要配置、V2 就绪证据、Windows 验收或 TrustedPeople 签名任一缺失,命令就不会注册。
|
||||
|
||||
安全边界:本档案只含对象名、模块编号、字段名和配置摘要;生成时没有读取业务单据行,没有执行存储过程,没有执行 INSERT/UPDATE/DELETE/DDL,也没有写回数据库。正式启用仍必须在客户 Windows ERP 进程中运行:
|
||||
|
||||
Agent 的内置管理员身份已收紧为 `user_id=1` 且员工名称精确为 `管理员`,不再继承旧客户端仅按显示名放行的行为。只读过程、请假写包装和采购写包装采用同样的成对判断;名称为“管理员”但 ID 不是 1 的账号仍必须通过对应菜单权限和业务范围门禁。若客户需要委派其他配置管理员,必须设计独立签字授权,不得复制或放宽该名称判断。
|
||||
|
||||
```text
|
||||
lserp-cli adapters inspect purchase acc_1007
|
||||
lserp-cli adapters inspect leave hr_4011
|
||||
lserp-cli adapters validate-fields purchase --input acc-1007.purchase.fields.candidate.json
|
||||
lserp-cli adapters validate-fields leave --input hr-4011.leave.fields.candidate.json
|
||||
lserp-cli adapters revalidate-profile --input lserp-ai.readonly-map.json
|
||||
lserp-cli adapters prepare-profile-activation purchase --input lserp-ai.readonly-map.json --field-mapping acc-1007.purchase.fields.final.json --read-evidence purchase.read-evidence.json --write-evidence purchase.write-evidence.json --runtime-sha256 <SHA256> --source-commit <COMMIT> --package-sha256 <SHA256> --output lserp-ai.purchase.approved-candidate.json
|
||||
```
|
||||
|
||||
最后一条命令只能由当前已登录 ERP 的内置管理员执行。它只运行代码内固定的一批 `DB_NAME/SERVERPROPERTY/sys.*` 系统目录结果集,比较数据库身份、兼容级别、用户对象计数、Agent 对象状态,以及档案声明的关键对象、列和过程参数;目录成员最多读取 100000 条,超限、缺少第二结果集、重复或格式异常都会失败关闭。它不会读取业务行、调用存储过程或执行写入。输出只包含档案 SHA-256、关键目录契约是否匹配、稳定漂移码、总阻断项数量,以及采购/请假各自的 `approved/openBlockerCount/openBlockerCodes`,不回显画像证据正文、当前数据库名、对象名、字段名、参数名或计数。命令级 `activationAllowed` 与 `registrationReady` 始终固定为 `false`;即使某个工作流 `approved=true`,该结果也不能单独启用写命令。签发脚本只接受本次工作流 `approved=true/openBlockerCount=0`,并且只允许 Agent 支撑对象部署引起的非关键计数/状态变化;运行门禁锁定批准状态、数据库身份、兼容级别和关键目录契约。只有画像哈希进入 RSA 签名工作流清单、与最终运行配置及 V2 就绪行共同通过,并在每次运行时复核仍匹配时,才满足完整门禁中的画像一项。
|
||||
|
||||
请假候选应在当前 ERP 管理员会话中通过字段门禁;仍需继续完成只读过程与写链路验收。请假写草案会把 ERP `user_id` 与只读上下文返回的当前员工再次绑定,只接受 `p_SubsysPurviewTab.hrPurview` 中菜单 `16629` 的完整编辑标记;`16629|` 只读标记必须拒绝。创建与提交分别以账套、子系统、用户、命令和幂等键获取事务级 `sp_getapplock`,防止多个 ERP/桌宠进程同时插入同一幂等请求。客户库当前仍没有这些 Agent 对象,草案也保持 `SET NOEXEC ON` 和审核开关为 `0`,因此上述加固不会造成任何实际写入。
|
||||
|
||||
采购候选刻意把物理列 `acc_mphhscm_currency` 写入 `currencyCode`:该列存在于主表,但没有暴露在 `acc_1007` 的低代码主表控件配置中,所以当前 `validate-fields purchase` 应以 `mapped_field_not_exposed` 或 `mapped_field_not_found` 失败关闭。字段检查会区分主表控件 `visible=1`(显示)与单据明细 `isVisible=1`(隐藏)这两套相反的旧框架语义,并把宽度为 0 或受字段权限隐藏的列排除在候选和注册范围外。只有客户配置人员正确暴露币种字段,并由财务/DBA 签字确认币种换算后,才可生成新的人工复核包。在此之前,不要把 `acc_1007` 写入 `LSERP_BUSINESS_ADAPTER_CONFIG` 的启用配置;不要把基础档案菜单 `acc_1002` 当成采购发票主从写入目标。
|
||||
|
||||
虽然 `acc_1007` 当前“来源单”配置只有物料类别和自身单据管理,没有采购订单来源,但两个现存数据库对象给出了可交叉验证的业务证据:`Proc_GetScmMainBillInfo` 和 `Scm_ScminvoiceMoneyView` 都用 `acc_lphhscm_ScmPoid = scm_lpo_id` 跟踪采购订单明细;`acc_lphhscm_ScmPrid` 则标注为“申请id”。因此兼容草案中的 `purchase.open_sources` 已改为固定、限量的只读候选查询,返回采购系统单据号、人工单号、明细 ID、单位、原币含税单价、税率、汇率和扣除现有有效发票占用后的剩余数量。它不仅复核采购订单菜单权限,还要求每一条返回行的组织、部门和采购员精确命中当前账套、子系统、ERP 用户的一条有效签字范围;范围表缺失、空表、过期、哈希无效或元组不匹配都会失败关闭,内置管理员也不绕过。这样不可写的采购订单不会先泄露给 AstrBot/MiniMax。草案继续受 `SET NOEXEC ON` 和 `@customer_dba_reviewed = 0` 双重锁定,不能因此启用采购写入。
|
||||
|
||||
采购订单币种来自 `P_CurrencyType`,而目标发票主表的物理币种列沿用 `P_BaseMixInfoTab(Tag='L000101')`。只读证据显示的 `1→22、2→23、3→24` 只是候选换算,不能硬编码为生产规则。兼容只读草案的 `resolve_currency` 现在只返回已存在有效签字映射的来源币种 ID;它可以精确接受来源字典的 ID/代码/名称,也可以接受发票 OCR 常见的目标字典 ID/编号/名称(例如“人民币元”),但后者仍必须先通过同一条已审批映射反查,映射缺失或失效时固定返回 `purchase_currency_crosswalk_not_approved`。这样后续 `open_sources` 和写包装始终接收采购订单使用的来源币种 ID,不会把目标字典 ID 误传给来源单查询。`acc_1007` 明细公式将 `amount * price` 作为含税金额,所以该客户的正式匹配配置必须使用 `lineAmountMode = 2`;单位或汇率缺失、同一发票命中多个汇率时均应失败关闭。
|
||||
|
||||
采购订单配置还明确暴露了组织、部门和采购员字段。基础 Agent 架构脚本现会创建默认空的 `p_agent_purchase_row_scope`,正式写包装要求来源单的组织、部门和采购员与当前账套、子系统、ERP 用户组成一个有效期内、带签字证据哈希的精确元组。该表不支持 NULL 或通配符,管理员也不绕过;客户未审批并填充范围时,写过程固定返回 `purchase_row_scope_denied`。这只建立了可执行的失败关闭机制,并不替客户决定谁能看哪些采购单,所以 `purchase_row_scope_not_approved` 仍保持打开。
|
||||
|
||||
ERP 网关只从固定客户包装过程异常中提取代码仓库白名单内的稳定业务码,并使用本地固定中文说明返回桌宠;SQL Server 原始异常、对象名、行号、连接信息和业务值不会向 AstrBot/MiniMax 透传。未知错误统一返回 `workflow_database_error`。因此币种字段缺失、换算未审批、精确采购范围未授权和请假权限/冲突可以被桌宠准确说明,同时不扩大数据库信息泄露面。
|
||||
|
||||
WinForm 保存链已经从源码和实库过程定义交叉验证:`GetDetailRecord` 先把明细写入 `ACC_billscmInvoicelistPIDHxtab_temp`,`BillSave` 再调用 `P_BillSavePr70`,由 `P_BillSavePr70_3` 生成正式单号、迁移明细并执行 ERP 保存副作用。因此采购兼容草案不会直接写最终主从表。目标临时明细的数量、单价、税率和汇率只有两位小数,候选合同会提前拒绝更高精度,避免旧保存链静默舍入。当前 `acc_1007` 保存事件不会自动提交;98/99 事件属于后续流转,桌宠创建动作也只允许生成草稿。
|
||||
|
||||
## 过程层结论
|
||||
|
||||
数据库目录中可以看到旧的通用过程(包括请假界面实际使用的 `p_BaseSave70` 和 `p_baseApply`),但没有 AgentBridge 固定调用的就绪、只读和强类型写包装过程。这些旧过程自身的参数契约不足以证明当前桌宠请求的账套/用户/模块权限、输入指纹、幂等占用、业务审计和事务证据,因此只能由固定客户包装过程调用,不能直接暴露为自然语言工具。
|
||||
|
||||
客户实施时应在 DBA 评审下,用固定参数化过程包裹现有 ERP 保存入口;先完成只读契约探针,再完成 SQL 事务、持久化幂等、权限复核和 Windows 集成验收。未完成前,AgentBridge 必须保持只读/导航/诊断能力,写命令不注册。
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"moduleCode": "acc_1007",
|
||||
"supplierCode": "acc_mphhscm_Providerid",
|
||||
"invoiceNumber": "acc_mphhscm_invoice",
|
||||
"invoiceDate": "acc_mphhscm_invoiceDate",
|
||||
"currencyCode": "acc_mphhscm_currency",
|
||||
"materialCode": "acc_lphhscm_productid",
|
||||
"unit": "acc_lphhscm_Productunitname",
|
||||
"quantity": "acc_lphhscm_amount",
|
||||
"unitPrice": "acc_lphhscm_price",
|
||||
"taxRate": "acc_lphhscm_taxRate",
|
||||
"exchangeRate": "acc_lphhscm_exchangeRate",
|
||||
"lineAmount": "acc_lphhscm_summoney",
|
||||
"sourceOrderId": "acc_lphhscm_sourcebillid",
|
||||
"sourceLineId": "acc_lphhscm_ScmPoid"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"moduleCode": "hr_4011",
|
||||
"employeeId": "hr_ela_empid",
|
||||
"leaveTypeCode": "hr_ela_type",
|
||||
"flowTypeCode": "hr_ela_billtype",
|
||||
"startLocal": "hr_ela_starttime",
|
||||
"endLocal": "hr_ela_finishtime",
|
||||
"requestedHours": "hr_ela_totals",
|
||||
"reason": "hr_ela_Leavebak"
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"schemaVersion": "1.2",
|
||||
"profileType": "readonly_low_code_metadata_review",
|
||||
"database": {
|
||||
"name": "lserp_AI",
|
||||
"sqlServerMajorVersion": 16,
|
||||
"compatibilityLevel": 100,
|
||||
"compatibilityContract": "fixed_scalar_and_schema_validated_xml_rowsets_in_trusted_erp_process",
|
||||
"userTableCount": 832,
|
||||
"userViewCount": 109,
|
||||
"userProcedureCount": 319,
|
||||
"userTriggerCount": 31,
|
||||
"agentWorkflowObjectsPresent": false,
|
||||
"criticalCatalogContract": {
|
||||
"contractVersion": "1.0",
|
||||
"requirements": [
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systembilltype",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["typeCode", "masterTable", "detailTable", "MasterSql", "DetailSql", "formKey"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "P_systemdlltab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["typeCode", "SQLDT1", "SQL", "formKey"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systembillInfo",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["typeCode", "fieldName", "defaultValue"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systembillDetail",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["typeCode", "fieldName", "isVisible"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "scm_BillPoMainTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["scm_mpo_Billdocument_Id", "scm_mpo_handcraftId", "scm_mpo_Providerid", "scm_mpo_Currency", "scm_mpo_ExchangeRate", "scm_mpo_stepover", "scm_mpo_cancelFlag", "scm_mpo_wasteoper", "scm_mpo_ban", "scm_mpo_Groupid", "scm_mpo_Departmentid", "scm_mpo_PurchaseUserId"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "scm_BillPolistTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["scm_lpo_Billdocument_id", "scm_lpo_id", "scm_lpo_Productid", "scm_lpo_ProductUnitName", "scm_lpo_Amount", "scm_lpo_Fprice", "scm_lpo_taxRate", "scm_lpo_ExchangeRate"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "ACC_billscmInvoicemainPIDHxtab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["acc_mphhscm_billdocument_id", "acc_mphhscm_groupid", "acc_mphhscm_Departmentid", "acc_mphhscm_Providerid", "acc_mphhscm_billtype", "acc_mphhscm_InvoiceType", "acc_mphhscm_SettlementType", "acc_mphhscm_invoice", "acc_mphhscm_summoney", "acc_mphhscm_nosummoney", "acc_mphhscm_taxsummoney", "acc_mphhscm_invoiceDate", "acc_mphhscm_operatorid", "acc_mphhscm_operatedate", "acc_mphhscm_currency", "acc_mphhscm_sign", "acc_mphhscm_Affirmer", "acc_mphhscm_cancelFlag", "acc_mphhscm_wasteoper", "acc_mphhscm_delid"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "ACC_billscmInvoicelistPIDHxtab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["acc_lphhscm_billdocument_id", "acc_lphhscm_productid", "acc_lphhscm_Productunitname", "acc_lphhscm_amount", "acc_lphhscm_price", "acc_lphhscm_summoney", "acc_lphhscm_taxsummoney", "acc_lphhscm_taxRate", "acc_lphhscm_exchangeRate", "acc_lphhscm_ScmPrid", "acc_lphhscm_ScmPoid", "acc_lphhscm_ScmJoinid", "acc_lphhscm_ScmDzid", "acc_lphhscm_sourcebillid", "acc_lphhscm_sourcename"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "ACC_billscmInvoicelistPIDHxtab_temp",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["acc_lphhscm_productid", "acc_lphhscm_Productunitname", "acc_lphhscm_amount", "acc_lphhscm_price", "acc_lphhscm_summoney", "acc_lphhscm_taxsummoney", "acc_lphhscm_taxRate", "acc_lphhscm_exchangeRate", "acc_lphhscm_ScmPrid", "acc_lphhscm_ScmPoid", "acc_lphhscm_ScmJoinid", "acc_lphhscm_ScmDzid", "acc_lphhscm_sourcebillid", "acc_lphhscm_sourcename", "sysstr"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "HR_EmpLeaveAloneTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["hr_ela_id", "hr_ela_no", "hr_ela_empid", "hr_ela_employeename", "hr_ela_depid", "hr_ela_postid", "hr_ela_type", "hr_ela_billtype", "hr_ela_starttime", "hr_ela_finishtime", "hr_ela_totals", "hr_ela_totals1", "hr_ela_Leavebak", "hr_ela_operatorid", "hr_ela_operatorname", "hr_ela_operatedate", "hr_ela_cancelFlag"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_employeetab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["employeeid", "EmployeeName", "p_emp_status", "Departmentid", "p_emp_postid"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_SubsysPurviewTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["employeeid", "scmPurview", "accPurview", "hrPurview"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systemdlltabflowtype",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["id", "typeCode", "billTypeName", "billflowban"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systemdlltabflowtypestep",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["typeCode", "billType", "stepClosed", "autoStep", "comfirmOper"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systemNotification",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["stepCode"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_baseflowOper",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["modid", "keyvalue", "billtype"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "HR_ScheduleTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["HR_Schedule_billdocument_d", "HR_Schedule_employeeid", "HR_Schedule_Date"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "P_SystemCheckParamTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["Billdocument_Id", "shichang"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "P_CurrencyType",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["id"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "P_BaseMixInfoTab",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["ID", "Tag", "Ban", "Delid"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_systembillflowtype",
|
||||
"objectKind": "table",
|
||||
"requiredColumns": ["id", "typeCode", "billflowban", "operUser"],
|
||||
"requiredParameters": []
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "P_BillSavePr70",
|
||||
"objectKind": "procedure",
|
||||
"requiredColumns": [],
|
||||
"requiredParameters": ["@Sql", "@billdocument_id", "@tmpstr", "@Operatorid", "@Fbilltagid", "@operateway", "@msg", "@auditFlag", "@comfirmFlag"]
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_BaseSave70",
|
||||
"objectKind": "procedure",
|
||||
"requiredColumns": [],
|
||||
"requiredParameters": ["@baseSql", "@saveType", "@modid", "@tablename", "@keyfield", "@keyvalue", "@Operatorid", "@operatorName", "@msg", "@comfirmFlag"]
|
||||
},
|
||||
{
|
||||
"schemaName": "dbo",
|
||||
"objectName": "p_baseApply",
|
||||
"objectKind": "procedure",
|
||||
"requiredColumns": [],
|
||||
"requiredParameters": ["@typeCode", "@billDocumentId", "@Operatorid", "@operatorName", "@comfirmType", "@msg"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
"source": "system_catalog_and_low_code_configuration_only",
|
||||
"businessRowsRead": false,
|
||||
"storedProceduresExecuted": false,
|
||||
"writesPerformed": false,
|
||||
"runtimeEnabled": false,
|
||||
"requiresCustomerReview": true
|
||||
},
|
||||
"purchaseTargetSelection": {
|
||||
"selectedModuleCode": "acc_1007",
|
||||
"selectedRole": "purchase_invoice_draft_write_candidate",
|
||||
"selectionState": "selected_but_activation_blocked",
|
||||
"activationAllowed": false,
|
||||
"onlineRevalidationRequiredBeforeActivation": true,
|
||||
"candidatesEvaluated": [
|
||||
{
|
||||
"moduleCode": "PUR_5001",
|
||||
"moduleKind": "bill",
|
||||
"decision": "source_only",
|
||||
"reasonCode": "purchase_order_source_only"
|
||||
},
|
||||
{
|
||||
"moduleCode": "acc_1002",
|
||||
"moduleKind": "base",
|
||||
"decision": "rejected",
|
||||
"reasonCode": "base_module_without_purchase_invoice_detail_contract"
|
||||
},
|
||||
{
|
||||
"moduleCode": "acc_1007",
|
||||
"moduleKind": "bill",
|
||||
"decision": "selected_but_activation_blocked",
|
||||
"reasonCodes": [
|
||||
"purchase_currency_field_not_configured",
|
||||
"purchase_currency_crosswalk_not_approved",
|
||||
"purchase_row_scope_not_approved",
|
||||
"purchase_compat100_write_contract_not_approved",
|
||||
"purchase_windows_integration_not_verified"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"purchaseActivationBlockers": [
|
||||
{
|
||||
"code": "purchase_currency_field_not_configured",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "acc_mphhscm_currency 是物理列,但 acc_1007 的 p_systembillInfo 没有对应主表控件。"
|
||||
},
|
||||
{
|
||||
"code": "purchase_currency_crosswalk_not_approved",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "采购订单与目标发票使用不同币种字典,候选 1→22、2→23、3→24 尚未由财务/DBA 签字。"
|
||||
},
|
||||
{
|
||||
"code": "purchase_row_scope_not_approved",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "已识别采购订单组织、部门和采购员字段,并补充默认空表、无通配符的精确签字范围契约;客户尚未批准或填充任何账套/子系统/ERP 用户范围。"
|
||||
},
|
||||
{
|
||||
"code": "purchase_compat100_write_contract_not_approved",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "已形成固定标量加受信任 XmlWriter 行集的 NOEXEC 审查草案并在客户库只编译通过;ERP 网关已有受商用证据门禁保护的精确分派,但 SQL 两道审核开关、运行配置和数据库 V2 就绪证据仍关闭。"
|
||||
},
|
||||
{
|
||||
"code": "purchase_windows_integration_not_verified",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "尚未在客户 Windows ERP 进程内完成保存链、回滚、幂等、审计和原生确认验收。"
|
||||
}
|
||||
],
|
||||
"leaveActivationBlockers": [
|
||||
{
|
||||
"code": "leave_flow_type_rules_stale",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "请假天数联动仍返回已不存在的旧流转配置行 3195-3200;当前有效行是 3629-3634,修复前只能要求用户显式选择候选。"
|
||||
},
|
||||
{
|
||||
"code": "leave_agent_schema_not_deployed",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "客户库尚无只读/写兼容过程、V2 就绪证据表及 Agent 幂等审计对象;当前只存在不落库的审查草案。"
|
||||
},
|
||||
{
|
||||
"code": "leave_compat100_write_contract_not_approved",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "固定强类型草案已补 ERP 用户作用域、hr_4011 编辑权限复核和按幂等键的数据库应用锁,但 DBA 审核开关仍固定关闭。"
|
||||
},
|
||||
{
|
||||
"code": "leave_windows_integration_not_verified",
|
||||
"status": "open",
|
||||
"resolution": null,
|
||||
"evidence": "尚未在客户 Windows ERP 进程内完成草稿创建、独立二次提交、回滚、重复请求、只读权限拒绝及审计验收。"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"menuId": 187,
|
||||
"caption": "采购订单下达",
|
||||
"moduleCode": "PUR_5001",
|
||||
"dllFileName": "Lskj.PubBill.dll",
|
||||
"subSystemId": 5,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"menuId": 2413,
|
||||
"caption": "采购发票登记",
|
||||
"moduleCode": "acc_1002",
|
||||
"dllFileName": "Lskj.PubModule.dll",
|
||||
"subSystemId": 10,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"menuId": 18848,
|
||||
"caption": "采购开票核销",
|
||||
"moduleCode": "acc_1007",
|
||||
"dllFileName": "Lskj.PubBill.dll",
|
||||
"subSystemId": 5,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"menuId": 16629,
|
||||
"caption": "请假申请管理",
|
||||
"moduleCode": "hr_4011",
|
||||
"dllFileName": "Lskj.PubModuleDetail.dll",
|
||||
"subSystemId": 7,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"modules": {
|
||||
"purchaseOrderSource": {
|
||||
"moduleCode": "PUR_5001",
|
||||
"kind": "bill",
|
||||
"masterTable": "scm_BillPoMainTab",
|
||||
"detailTable": "scm_BillPolistTab",
|
||||
"formKey": "89010284-C0C0-4A64-996D-7F0FDB9C83B0",
|
||||
"sourceFields": {
|
||||
"supplierCode": "scm_mpo_Providerid",
|
||||
"currencyCode": "scm_mpo_Currency",
|
||||
"materialCode": "scm_lpo_Productid",
|
||||
"unit": "scm_lpo_ProductUnitName",
|
||||
"quantity": "scm_lpo_Amount",
|
||||
"unitPrice": "scm_lpo_Fprice",
|
||||
"taxRate": "scm_lpo_taxRate",
|
||||
"exchangeRate": "scm_lpo_ExchangeRate",
|
||||
"lineAmount": "scm_lpo_FSummoney",
|
||||
"sourceOrderId": "scm_lpo_Billdocument_id",
|
||||
"sourceOrderNumber": "scm_mpo_handcraftId",
|
||||
"sourceLineId": "scm_lpo_id"
|
||||
}
|
||||
},
|
||||
"purchaseInvoiceWriteCandidate": {
|
||||
"moduleCode": "acc_1007",
|
||||
"kind": "bill",
|
||||
"caption": "采购发票核销",
|
||||
"masterTable": "ACC_billscmInvoicemainPIDHxtab",
|
||||
"detailTable": "ACC_billscmInvoicelistPIDHxtab",
|
||||
"formKey": "E7CF6797-E68F-4922-925D-B2BD6165B249",
|
||||
"requiredMatchOptions": {
|
||||
"lineAmountMode": 2,
|
||||
"unitPriceBasis": "purchase_order_original_currency_tax_inclusive",
|
||||
"mixedExchangeRatesAllowed": false
|
||||
},
|
||||
"compatibilityWriteCandidate": {
|
||||
"procedure": "p_lserp_agent_workflow_write_purchase_compat100",
|
||||
"status": "inert_review_draft",
|
||||
"inputContract": "fixed_scalars_and_schema_validated_xml_rowsets",
|
||||
"legacySaveChain": "P_BillSavePr70_to_P_BillSavePr70_3",
|
||||
"creates": "unsubmitted_draft_only",
|
||||
"rowScopeContract": "exact_signed_account_subsystem_erp_user_group_department_purchase_user_tuple",
|
||||
"gatewayRoute": "implemented_fail_closed_by_commercial_readiness_and_signed_acceptance",
|
||||
"runtimeRegistered": false
|
||||
},
|
||||
"fieldMap": {
|
||||
"supplierCode": "acc_mphhscm_Providerid",
|
||||
"invoiceNumber": "acc_mphhscm_invoice",
|
||||
"invoiceDate": "acc_mphhscm_invoiceDate",
|
||||
"currencyCode": "acc_mphhscm_currency",
|
||||
"materialCode": "acc_lphhscm_productid",
|
||||
"unit": "acc_lphhscm_Productunitname",
|
||||
"quantity": "acc_lphhscm_amount",
|
||||
"unitPrice": "acc_lphhscm_price",
|
||||
"taxRate": "acc_lphhscm_taxRate",
|
||||
"exchangeRate": "acc_lphhscm_exchangeRate",
|
||||
"lineAmount": "acc_lphhscm_summoney",
|
||||
"sourceOrderId": "acc_lphhscm_sourcebillid",
|
||||
"sourceLineId": "acc_lphhscm_ScmPoid"
|
||||
},
|
||||
"reviewFindings": [
|
||||
"主表物理列 acc_mphhscm_currency 存在,但 p_systembillInfo 未为 acc_1007 暴露该主表控件;字段门禁必须失败关闭。",
|
||||
"采购订单 scm_mpo_Currency 使用 P_CurrencyType.id(当前只读证据为 1/2/3),目标发票币种使用 P_BaseMixInfoTab Tag=L000101(当前只读证据为 22/23/24);1→22、2→23、3→24 只能作为待签字候选映射。",
|
||||
"resolve_currency 只通过有效签字映射返回采购订单使用的来源币种 ID;来源字典 ID/代码/名称与发票目标字典 ID/编号/名称均为精确匹配,未审批映射固定失败关闭。",
|
||||
"Proc_GetScmMainBillInfo 与 Scm_ScminvoiceMoneyView 均证明 acc_lphhscm_ScmPoid 对应 scm_lpo_id;acc_lphhscm_ScmPrid 标注为申请id,不得当成采购订单明细。",
|
||||
"acc_1007 的 p_systembillsource 未配置采购订单,但现有流程过程和视图已给出来源行语义;open_sources 只读草案可供 DBA 审核,全部激活门禁完成前采购写入仍不得注册。",
|
||||
"采购订单低代码配置明确暴露组织 scm_mpo_Groupid、部门 scm_mpo_Departmentid 和采购员 scm_mpo_PurchaseUserId;兼容草案要求三者与当前账套、子系统、ERP 用户组成一个有效期内且带签字哈希的精确范围元组,不支持 NULL 或通配符,空表默认拒绝。",
|
||||
"acc_1007 明细公式使用 amount*price 作为含税金额,正式匹配必须配置 lineAmountMode=2(TaxInclusive)。",
|
||||
"WinForm 的 GetAddRecord/GetDetailRecord/BillSave 会先把明细写入 ACC_billscmInvoicelistPIDHxtab_temp,再由 P_BillSavePr70 路由到 P_BillSavePr70_3;正式包装不得绕开这条保存链直接插入最终主从表。",
|
||||
"P_BillSavePr70_3 会生成正式单号、迁移临时明细、写 ERP 日志并调用 eventType=1 业务处理;acc_1007 的当前保存事件没有额外配置逻辑,eventType=98/99 属于后续流转,所以 Agent 创建只能停在未提交草稿。",
|
||||
"临时明细表的数量、单价、税率和汇率均为 decimal(18,2),兼容写候选因此拒绝超过两位小数的值,避免保存链静默舍入后破坏来源快照。",
|
||||
"acc_1007 当前唯一可用流转类别是 p_systembillflowtype.id=4869,且只向其 operUser 中的操作员开放;包装过程必须在保存前重新校验。"
|
||||
]
|
||||
},
|
||||
"purchaseInvoiceRegistrationMenu": {
|
||||
"moduleCode": "acc_1002",
|
||||
"kind": "base",
|
||||
"table": "ACC_billscminvoicemaintab",
|
||||
"formKey": "BAE677C9-18DE-4257-8E83-C4D88B33371D",
|
||||
"use": "navigation_or_read_only_diagnosis_only",
|
||||
"reason": "该菜单是基础档案型发票登记,当前没有采购明细字段,不能替代 acc_1007 的主从单据写入契约。"
|
||||
},
|
||||
"leave": {
|
||||
"moduleCode": "hr_4011",
|
||||
"kind": "base",
|
||||
"table": "HR_EmpLeaveAloneTab",
|
||||
"formKey": "65C24153-A824-40E8-B8D9-CD817E448DEF",
|
||||
"compatibilityWriteCandidate": {
|
||||
"procedure": "p_lserp_agent_workflow_write_leave_compat100",
|
||||
"status": "inert_review_draft",
|
||||
"inputContract": "fixed_typed_scalars",
|
||||
"legacyCreateChain": "p_BaseSave70",
|
||||
"legacySubmitChain": "p_baseApply",
|
||||
"menuPermission": "edit_token_16629_only",
|
||||
"approvalSchemaColumnsPresent": true,
|
||||
"gatewayRoute": "implemented_fail_closed_by_commercial_readiness_and_signed_acceptance",
|
||||
"runtimeRegistered": false
|
||||
},
|
||||
"fieldMap": {
|
||||
"employeeId": "hr_ela_empid",
|
||||
"leaveTypeCode": "hr_ela_type",
|
||||
"flowTypeCode": "hr_ela_billtype",
|
||||
"startLocal": "hr_ela_starttime",
|
||||
"endLocal": "hr_ela_finishtime",
|
||||
"requestedHours": "hr_ela_totals",
|
||||
"reason": "hr_ela_Leavebak"
|
||||
},
|
||||
"derivedWriteFields": {
|
||||
"employeeName": "hr_ela_employeename",
|
||||
"departmentId": "hr_ela_depid",
|
||||
"postId": "hr_ela_postid",
|
||||
"calculatedDays": "hr_ela_totals1",
|
||||
"operatorId": "hr_ela_operatorid",
|
||||
"operatorName": "hr_ela_operatorname",
|
||||
"operateDate": "hr_ela_operatedate"
|
||||
},
|
||||
"flowTypeLookup": {
|
||||
"candidateCode": "p_systemdlltabflowtype.id",
|
||||
"candidateName": "p_systemdlltabflowtype.billTypeName",
|
||||
"stepJoin": "p_systemdlltabflowtypestep.billType = CONVERT(varchar(10), p_systemdlltabflowtype.id)"
|
||||
},
|
||||
"reviewFindings": [
|
||||
"流转类别控件以配置行 id 为保存值;p_systemdlltabflowtype.billType 存在重复值,不能作为 Agent 候选编码。",
|
||||
"请假天数联动配置仍返回已不存在的旧流转 id 3195-3200,而当前有效配置 id 为 3629-3634;正式修复前必须显式选择流转类别,禁止自动推断。",
|
||||
"审批步骤会引用 hr_ela_depid;Agent 写过程必须从请假人员主数据重新派生并保存部门、岗位和姓名快照。",
|
||||
"p_BaseSave70_3 的成功路径会产生多个旧调试结果集;ERP 网关必须忽略这些输出,并且只接受列集合完全匹配固定 BusinessWriteResult 的唯一结果集。",
|
||||
"p_SubsysPurviewTab.hrPurview 是 hr_4011 的权限来源;16629| 仅为只读,兼容写草案只接受完整编辑标记 16629,并在确认后、保存前再次复核。",
|
||||
"p_systemdlltabflowtypestep.autoStep/comfirmOper 与 p_systemNotification.stepCode 当前物理列均存在,提交草案仍会在每次调用前复核,禁止触发 p_baseApply 的旧自迁移分支。"
|
||||
],
|
||||
"workflowDependencies": [
|
||||
"p_employeetab",
|
||||
"P_EmployeePostTab",
|
||||
"P_DepartmentTab",
|
||||
"p_systemdlltabflow",
|
||||
"p_systemdlltabflowtype",
|
||||
"p_systemdlltabflowtypestep",
|
||||
"p_baseflowOper"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1321
File diff suppressed because it is too large
Load Diff
+765
@@ -0,0 +1,765 @@
|
||||
/*
|
||||
Customer-specific review draft for hr_4011 leave writes in the lserp_AI
|
||||
compatibility-level-100 database.
|
||||
|
||||
THIS FILE IS INERT:
|
||||
1. SET NOEXEC ON prevents CREATE PROCEDURE from being applied.
|
||||
2. @customer_dba_reviewed is deliberately fixed to 0.
|
||||
3. The script must never be edited into a production deployment artifact. A customer
|
||||
DBA must create and sign a separate script after reviewing every dependency below.
|
||||
|
||||
Reviewed design assumptions that still require customer DBA acceptance:
|
||||
- p_lserp_agent_workflow_read_compat100 is the approved fixed-parameter read contract;
|
||||
- hr_4011 is a newVer=1 base module backed by HR_EmpLeaveAloneTab;
|
||||
- p_BaseSave70 is the exact ERP create path and generates hr_ela_no through the
|
||||
configured document-number rules;
|
||||
- p_baseApply is the exact ERP submit path;
|
||||
- p_SubsysPurviewTab.hrPurview is the customer menu permission source, menu
|
||||
16629 is hr_4011, and only the edit token (without the read-only "|" suffix)
|
||||
authorizes a write;
|
||||
- p_agent_command_idempotency, p_agent_business_audit and
|
||||
p_agent_integration_outbox were created from 001_agent_business_idempotency.sql;
|
||||
- the legacy procedures may emit diagnostic result sets; the trusted ERP gateway
|
||||
discards those and accepts exactly one result set whose ten columns exactly match
|
||||
the fixed BusinessWriteResult contract;
|
||||
- the fixed INSERT column list below matches the approved UI save payload, including
|
||||
nullable department/post and secondary-hours fields that are intentionally omitted.
|
||||
|
||||
No table name, column name, SQL fragment or procedure name is accepted from the Agent.
|
||||
The only dynamic SQL string is an internally constructed, fixed-template @base_sql
|
||||
required by the legacy p_BaseSave70 contract. Every text value is length checked and
|
||||
escaped inside this wrapper.
|
||||
*/
|
||||
SET NOEXEC ON;
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE dbo.p_lserp_agent_workflow_write_leave_compat100
|
||||
@action VARCHAR(64),
|
||||
@module_code NVARCHAR(64),
|
||||
@account_book NVARCHAR(64),
|
||||
@subsystem_id NVARCHAR(32),
|
||||
@user_id NVARCHAR(64),
|
||||
@correlation_id VARCHAR(128),
|
||||
@idempotency_key VARCHAR(128),
|
||||
@input_fingerprint CHAR(64),
|
||||
@employee_id NVARCHAR(64) = NULL,
|
||||
@leave_type_code NVARCHAR(64) = NULL,
|
||||
@flow_type_code NVARCHAR(64) = NULL,
|
||||
@start_local DATETIME = NULL,
|
||||
@end_local DATETIME = NULL,
|
||||
@requested_hours DECIMAL(18, 6) = NULL,
|
||||
@reason NVARCHAR(500) = NULL,
|
||||
@submit_after_save_intent BIT = NULL,
|
||||
@record_id NVARCHAR(128) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @customer_dba_reviewed BIT;
|
||||
SET @customer_dba_reviewed = 0;
|
||||
|
||||
IF @customer_dba_reviewed <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'customer_dba_review_required', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF DB_NAME() <> N'lserp_AI'
|
||||
OR @module_code <> N'hr_4011'
|
||||
OR @subsystem_id <> N'7'
|
||||
OR @action NOT IN ('create_draft', 'submit')
|
||||
OR NULLIF(LTRIM(RTRIM(@account_book)), N'') IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@user_id)), N'') IS NULL
|
||||
OR @@TRANCOUNT < 1 OR XACT_STATE() <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_write_scope_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF @correlation_id IS NULL
|
||||
OR LEN(@correlation_id) < 8 OR LEN(@correlation_id) > 128
|
||||
OR @correlation_id LIKE '%[^A-Za-z0-9_.:-]%'
|
||||
OR @idempotency_key IS NULL
|
||||
OR LEN(@idempotency_key) < 8 OR LEN(@idempotency_key) > 128
|
||||
OR @idempotency_key LIKE '%[^A-Za-z0-9_.:-]%'
|
||||
OR @input_fingerprint IS NULL OR LEN(@input_fingerprint) <> 64
|
||||
OR @input_fingerprint LIKE '%[^A-Fa-f0-9]%'
|
||||
BEGIN
|
||||
RAISERROR(N'leave_write_evidence_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @context TABLE
|
||||
(
|
||||
current_employee_id NVARCHAR(64) NOT NULL,
|
||||
can_apply_for_others BIT NOT NULL,
|
||||
now_local DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO @context
|
||||
(current_employee_id, can_apply_for_others, now_local)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'context',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id;
|
||||
|
||||
IF (SELECT COUNT_BIG(*) FROM @context) <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_context_contract_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @current_employee_text NVARCHAR(64);
|
||||
DECLARE @can_apply_for_others BIT;
|
||||
DECLARE @now_local DATETIME;
|
||||
SELECT
|
||||
@current_employee_text = current_employee_id,
|
||||
@can_apply_for_others = can_apply_for_others,
|
||||
@now_local = now_local
|
||||
FROM @context;
|
||||
|
||||
IF LEN(@current_employee_text) > 10
|
||||
OR @current_employee_text LIKE N'%[^0-9]%'
|
||||
BEGIN
|
||||
RAISERROR(N'leave_context_contract_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @current_employee_number DECIMAL(10, 0);
|
||||
DECLARE @current_employee_id INT;
|
||||
SET @current_employee_number = CONVERT(DECIMAL(10, 0), @current_employee_text);
|
||||
IF @current_employee_number < 1 OR @current_employee_number > 2147483647
|
||||
BEGIN
|
||||
RAISERROR(N'leave_context_contract_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
SET @current_employee_id = CONVERT(INT, @current_employee_number);
|
||||
|
||||
IF @user_id IS NULL OR LEN(@user_id) > 10
|
||||
OR @user_id LIKE N'%[^0-9]%'
|
||||
BEGIN
|
||||
RAISERROR(N'leave_user_scope_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
DECLARE @user_number DECIMAL(10, 0);
|
||||
SET @user_number = CONVERT(DECIMAL(10, 0), @user_id);
|
||||
IF @user_number < 1 OR @user_number > 2147483647
|
||||
OR CONVERT(INT, @user_number) <> @current_employee_id
|
||||
BEGIN
|
||||
RAISERROR(N'leave_user_scope_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @operator_name NVARCHAR(50);
|
||||
SELECT @operator_name = CONVERT(NVARCHAR(50), EmployeeName)
|
||||
FROM dbo.p_employeetab WITH (HOLDLOCK)
|
||||
WHERE employeeid = @current_employee_id
|
||||
AND ISNULL(p_emp_status, '') <> N'离职';
|
||||
IF @operator_name IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N'leave_context_contract_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
/*
|
||||
Read-only menu tokens end in "|". A write must have the exact edit token
|
||||
",16629,"; the trusted WinForm authorizer performs the same check before
|
||||
confirmation, and this database check closes the time-of-check/use gap.
|
||||
*/
|
||||
DECLARE @leave_permission_text NVARCHAR(MAX);
|
||||
SET @leave_permission_text = N',';
|
||||
SELECT TOP (1)
|
||||
@leave_permission_text = N','
|
||||
+ CONVERT(NVARCHAR(MAX), ISNULL(hrPurview, '')) + N','
|
||||
FROM dbo.p_SubsysPurviewTab WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE employeeid = @current_employee_id;
|
||||
IF (@current_employee_id <> 1 OR @operator_name <> N'管理员')
|
||||
AND CHARINDEX(N',16629,', @leave_permission_text) = 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_write_permission_denied', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @command_name VARCHAR(128);
|
||||
SET @command_name = CASE WHEN @action = 'create_draft'
|
||||
THEN 'hr.leave.create' ELSE 'hr.leave.submit' END;
|
||||
|
||||
IF OBJECT_ID(N'dbo.p_agent_command_idempotency', N'U') IS NULL
|
||||
OR OBJECT_ID(N'dbo.p_agent_business_audit', N'U') IS NULL
|
||||
OR OBJECT_ID(N'dbo.p_agent_integration_outbox', N'U') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N'leave_agent_evidence_schema_missing', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @application_lock_result INT;
|
||||
DECLARE @application_lock_resource NVARCHAR(255);
|
||||
SET @application_lock_resource = N'lserp-agent:leave:'
|
||||
+ LEFT(@account_book, 40) + N':' + LEFT(@subsystem_id, 20)
|
||||
+ N':' + LEFT(@user_id, 10) + N':' + @command_name
|
||||
+ N':' + CONVERT(NVARCHAR(128), @idempotency_key);
|
||||
EXEC @application_lock_result = sys.sp_getapplock
|
||||
@Resource = @application_lock_resource,
|
||||
@LockMode = 'Exclusive',
|
||||
@LockOwner = 'Transaction',
|
||||
@LockTimeout = 10000;
|
||||
IF @application_lock_result < 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_idempotency_lock_failed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @idempotency_id BIGINT;
|
||||
DECLARE @stored_fingerprint CHAR(64);
|
||||
DECLARE @stored_status TINYINT;
|
||||
DECLARE @stored_result_code VARCHAR(128);
|
||||
DECLARE @stored_record_id NVARCHAR(128);
|
||||
DECLARE @stored_transaction_id VARCHAR(128);
|
||||
DECLARE @stored_audit_id VARCHAR(128);
|
||||
|
||||
SELECT
|
||||
@idempotency_id = id,
|
||||
@stored_fingerprint = input_fingerprint,
|
||||
@stored_status = status,
|
||||
@stored_result_code = result_code,
|
||||
@stored_record_id = record_id,
|
||||
@stored_transaction_id = transaction_evidence_id,
|
||||
@stored_audit_id = business_audit_id
|
||||
FROM dbo.p_agent_command_idempotency WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE account_book = @account_book
|
||||
AND subsystem_id = @subsystem_id
|
||||
AND user_id = @user_id
|
||||
AND command_name = @command_name
|
||||
AND idempotency_key = @idempotency_key;
|
||||
|
||||
IF @idempotency_id IS NOT NULL
|
||||
AND @stored_fingerprint <> @input_fingerprint
|
||||
BEGIN
|
||||
SELECT
|
||||
CONVERT(BIT, 0) AS success,
|
||||
CONVERT(VARCHAR(128), 'idempotency_key_conflict') AS code,
|
||||
CONVERT(NVARCHAR(1000), N'同一幂等键已绑定不同的业务输入。') AS message,
|
||||
CONVERT(NVARCHAR(128), NULL) AS record_id,
|
||||
CONVERT(BIT, 0) AS needs_ui,
|
||||
CONVERT(BIT, 0) AS idempotency_replayed,
|
||||
@idempotency_key AS applied_idempotency_key,
|
||||
@input_fingerprint AS applied_input_fingerprint,
|
||||
CONVERT(VARCHAR(128), NULL) AS transaction_evidence_id,
|
||||
CONVERT(VARCHAR(128), NULL) AS business_audit_id;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF @idempotency_id IS NOT NULL AND @stored_status = 1
|
||||
BEGIN
|
||||
SELECT
|
||||
CONVERT(BIT, 1) AS success,
|
||||
@stored_result_code AS code,
|
||||
CONVERT(NVARCHAR(1000), N'已返回同一幂等请求的原事务结果。') AS message,
|
||||
@stored_record_id AS record_id,
|
||||
CONVERT(BIT, 0) AS needs_ui,
|
||||
CONVERT(BIT, 1) AS idempotency_replayed,
|
||||
@idempotency_key AS applied_idempotency_key,
|
||||
@input_fingerprint AS applied_input_fingerprint,
|
||||
@stored_transaction_id AS transaction_evidence_id,
|
||||
@stored_audit_id AS business_audit_id;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF @idempotency_id IS NOT NULL
|
||||
BEGIN
|
||||
SELECT
|
||||
CONVERT(BIT, 0) AS success,
|
||||
CONVERT(VARCHAR(128), 'idempotency_request_not_replayable') AS code,
|
||||
CONVERT(NVARCHAR(1000), N'同一幂等请求尚未形成可重放的成功结果。') AS message,
|
||||
CONVERT(NVARCHAR(128), NULL) AS record_id,
|
||||
CONVERT(BIT, 0) AS needs_ui,
|
||||
CONVERT(BIT, 0) AS idempotency_replayed,
|
||||
@idempotency_key AS applied_idempotency_key,
|
||||
@input_fingerprint AS applied_input_fingerprint,
|
||||
CONVERT(VARCHAR(128), NULL) AS transaction_evidence_id,
|
||||
CONVERT(VARCHAR(128), NULL) AS business_audit_id;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
INSERT INTO dbo.p_agent_command_idempotency
|
||||
(
|
||||
account_book, subsystem_id, user_id, command_name,
|
||||
idempotency_key, input_fingerprint, status
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@account_book, @subsystem_id, @user_id, @command_name,
|
||||
@idempotency_key, @input_fingerprint, 0
|
||||
);
|
||||
SET @idempotency_id = CONVERT(BIGINT, SCOPE_IDENTITY());
|
||||
|
||||
DECLARE @effective_record_id NVARCHAR(128);
|
||||
DECLARE @result_code VARCHAR(128);
|
||||
DECLARE @result_message NVARCHAR(1000);
|
||||
|
||||
IF @action = 'create_draft'
|
||||
BEGIN
|
||||
SET @employee_id = NULLIF(LTRIM(RTRIM(@employee_id)), N'');
|
||||
SET @leave_type_code = NULLIF(LTRIM(RTRIM(@leave_type_code)), N'');
|
||||
SET @flow_type_code = NULLIF(LTRIM(RTRIM(@flow_type_code)), N'');
|
||||
SET @reason = NULLIF(LTRIM(RTRIM(@reason)), N'');
|
||||
|
||||
IF @employee_id IS NULL OR LEN(@employee_id) > 10
|
||||
OR @employee_id LIKE N'%[^0-9]%'
|
||||
OR @leave_type_code IS NULL OR LEN(@leave_type_code) > 10
|
||||
OR @leave_type_code LIKE N'%[^0-9]%'
|
||||
OR @flow_type_code IS NULL OR LEN(@flow_type_code) > 10
|
||||
OR @flow_type_code LIKE N'%[^0-9]%'
|
||||
OR @start_local IS NULL OR @end_local IS NULL
|
||||
OR @end_local <= @start_local
|
||||
OR DATEDIFF(DAY, CONVERT(DATE, @start_local),
|
||||
CONVERT(DATE, @end_local)) > 31
|
||||
OR @requested_hours IS NULL
|
||||
OR @requested_hours < 0 OR @requested_hours > 744
|
||||
OR @reason IS NULL OR LEN(@reason) < 2 OR LEN(@reason) > 500
|
||||
OR DATALENGTH(CONVERT(VARCHAR(8000), @reason)) > 500
|
||||
OR CONVERT(NVARCHAR(500), CONVERT(VARCHAR(500), @reason)) <> @reason
|
||||
BEGIN
|
||||
RAISERROR(N'leave_create_input_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @control_code INT;
|
||||
SET @control_code = 1;
|
||||
WHILE @control_code <= 31
|
||||
BEGIN
|
||||
IF CHARINDEX(NCHAR(@control_code), @reason) > 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_create_input_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
SET @control_code = @control_code + 1;
|
||||
END;
|
||||
IF CHARINDEX(NCHAR(127), @reason) > 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_create_input_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @requested_employee_number DECIMAL(10, 0);
|
||||
DECLARE @leave_type_number DECIMAL(10, 0);
|
||||
DECLARE @flow_type_number DECIMAL(10, 0);
|
||||
SET @requested_employee_number = CONVERT(DECIMAL(10, 0), @employee_id);
|
||||
SET @leave_type_number = CONVERT(DECIMAL(10, 0), @leave_type_code);
|
||||
SET @flow_type_number = CONVERT(DECIMAL(10, 0), @flow_type_code);
|
||||
IF @requested_employee_number < 1
|
||||
OR @requested_employee_number > 2147483647
|
||||
OR @leave_type_number < 1 OR @leave_type_number > 2147483647
|
||||
OR @flow_type_number < 1 OR @flow_type_number > 2147483647
|
||||
OR (@requested_employee_number <> @current_employee_id
|
||||
AND ISNULL(@can_apply_for_others, 0) <> 1)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_apply_for_others_denied', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF @start_local < CONVERT(DATE, @now_local)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_past_start_denied', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @type_state TABLE (enabled BIT NOT NULL);
|
||||
INSERT INTO @type_state(enabled)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'type_enabled',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id,
|
||||
@leave_type_code = @leave_type_code;
|
||||
IF (SELECT COUNT_BIG(*) FROM @type_state) <> 1
|
||||
OR NOT EXISTS (SELECT 1 FROM @type_state WHERE enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_type_not_enabled', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @flow_state TABLE (enabled BIT NOT NULL);
|
||||
INSERT INTO @flow_state(enabled)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'flow_type_enabled',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id,
|
||||
@flow_type_code = @flow_type_code;
|
||||
IF (SELECT COUNT_BIG(*) FROM @flow_state) <> 1
|
||||
OR NOT EXISTS (SELECT 1 FROM @flow_state WHERE enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_flow_type_not_enabled', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @hours_state TABLE (hours DECIMAL(18, 6) NOT NULL);
|
||||
INSERT INTO @hours_state(hours)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'calculate_hours',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id,
|
||||
@employee_id = @employee_id,
|
||||
@start_local = @start_local,
|
||||
@end_local = @end_local;
|
||||
IF (SELECT COUNT_BIG(*) FROM @hours_state) <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_hours_contract_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @calculated_hours DECIMAL(18, 6);
|
||||
SELECT @calculated_hours = hours FROM @hours_state;
|
||||
IF @calculated_hours <= 0
|
||||
OR (@requested_hours > 0
|
||||
AND ABS(@requested_hours - @calculated_hours) > 0.01)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_hours_mismatch', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @conflict_state TABLE (has_conflict BIT NOT NULL);
|
||||
INSERT INTO @conflict_state(has_conflict)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'has_conflict',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id,
|
||||
@employee_id = @employee_id,
|
||||
@start_local = @start_local,
|
||||
@end_local = @end_local;
|
||||
IF (SELECT COUNT_BIG(*) FROM @conflict_state) <> 1
|
||||
OR EXISTS (SELECT 1 FROM @conflict_state WHERE has_conflict = 1)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_time_conflict', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @employee_name NVARCHAR(50);
|
||||
DECLARE @employee_department_id INT;
|
||||
DECLARE @employee_post_id INT;
|
||||
SELECT
|
||||
@employee_name = CONVERT(NVARCHAR(50), EmployeeName),
|
||||
@employee_department_id = Departmentid,
|
||||
@employee_post_id = p_emp_postid
|
||||
FROM dbo.p_employeetab WITH (HOLDLOCK)
|
||||
WHERE employeeid = CONVERT(INT, @requested_employee_number)
|
||||
AND ISNULL(p_emp_status, '') <> N'离职';
|
||||
IF @employee_name IS NULL
|
||||
OR @employee_department_id IS NULL
|
||||
OR @employee_post_id IS NULL
|
||||
OR DATALENGTH(CONVERT(VARCHAR(8000), @employee_name)) > 50
|
||||
OR DATALENGTH(CONVERT(VARCHAR(8000), @operator_name)) > 10
|
||||
BEGIN
|
||||
RAISERROR(N'leave_employee_not_available', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @standard_day_hours DECIMAL(18, 6);
|
||||
DECLARE @calculated_days DECIMAL(18, 6);
|
||||
SELECT TOP (1)
|
||||
@standard_day_hours = CONVERT(DECIMAL(18, 6),
|
||||
CASE WHEN ISNULL(check_param.shichang, 0) = 0
|
||||
THEN 7.5 ELSE check_param.shichang END)
|
||||
FROM dbo.HR_ScheduleTab AS schedule WITH (HOLDLOCK)
|
||||
LEFT JOIN dbo.P_SystemCheckParamTab AS check_param WITH (HOLDLOCK)
|
||||
ON schedule.HR_Schedule_billdocument_d = check_param.Billdocument_Id
|
||||
WHERE schedule.HR_Schedule_employeeid =
|
||||
CONVERT(INT, @requested_employee_number)
|
||||
AND schedule.HR_Schedule_Date <= CONVERT(DATE, @start_local)
|
||||
ORDER BY schedule.HR_Schedule_Date DESC;
|
||||
IF @standard_day_hours IS NULL OR @standard_day_hours <= 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_day_hours_not_configured', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
SET @calculated_days = ROUND(
|
||||
@calculated_hours / @standard_day_hours, 1);
|
||||
IF @calculated_days <= 0 OR @calculated_days > 366
|
||||
BEGIN
|
||||
RAISERROR(N'leave_calculated_days_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @number_lock_result INT;
|
||||
EXEC @number_lock_result = sys.sp_getapplock
|
||||
@Resource = N'lserp-agent:hr_4011:document-number',
|
||||
@LockMode = 'Exclusive',
|
||||
@LockOwner = 'Transaction',
|
||||
@LockTimeout = 10000;
|
||||
IF @number_lock_result < 0
|
||||
BEGIN
|
||||
RAISERROR(N'leave_document_number_lock_failed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
/*
|
||||
Hold the identity tail until the outer transaction completes. The legacy
|
||||
save procedure can overwrite @msg after assigning the document number, so
|
||||
the created row is identified by the protected identity interval instead of
|
||||
trusting that output text.
|
||||
*/
|
||||
DECLARE @identity_before INT;
|
||||
SELECT @identity_before = ISNULL(MAX(hr_ela_id), 0)
|
||||
FROM dbo.HR_EmpLeaveAloneTab WITH (UPDLOCK, HOLDLOCK);
|
||||
|
||||
DECLARE @base_sql NVARCHAR(MAX);
|
||||
SET @base_sql =
|
||||
N'INSERT INTO dbo.HR_EmpLeaveAloneTab '
|
||||
+ N'(hr_ela_empid,hr_ela_employeename,hr_ela_depid,hr_ela_postid,'
|
||||
+ N'hr_ela_type,hr_ela_billtype,'
|
||||
+ N'hr_ela_Leavebak,hr_ela_starttime,hr_ela_finishtime,'
|
||||
+ N'hr_ela_totals,hr_ela_totals1,hr_ela_operatorid,hr_ela_operatorname,'
|
||||
+ N'hr_ela_operatedate) VALUES ('
|
||||
+ CONVERT(NVARCHAR(20), CONVERT(INT, @requested_employee_number))
|
||||
+ N',N''' + REPLACE(@employee_name, N'''', N'''''') + N''','
|
||||
+ N'N''' + CONVERT(NVARCHAR(20), @employee_department_id) + N''','
|
||||
+ N'N''' + CONVERT(NVARCHAR(20), @employee_post_id) + N''','
|
||||
+ CONVERT(NVARCHAR(20), CONVERT(INT, @leave_type_number))
|
||||
+ N',' + CONVERT(NVARCHAR(20), CONVERT(INT, @flow_type_number)) + N','
|
||||
+ N'N''' + REPLACE(@reason, N'''', N'''''') + N''','''
|
||||
+ CONVERT(NVARCHAR(23), @start_local, 121) + N''','''
|
||||
+ CONVERT(NVARCHAR(23), @end_local, 121) + N''','
|
||||
+ CONVERT(NVARCHAR(50), @calculated_hours) + N','
|
||||
+ CONVERT(NVARCHAR(50), @calculated_days) + N','
|
||||
+ CONVERT(NVARCHAR(20), @current_employee_id) + N',N'''
|
||||
+ REPLACE(@operator_name, N'''', N'''''') + N''','''
|
||||
+ CONVERT(NVARCHAR(23), @now_local, 121) + N''')';
|
||||
|
||||
DECLARE @legacy_message NVARCHAR(4000);
|
||||
DECLARE @legacy_return INT;
|
||||
SET @legacy_message = N'';
|
||||
EXEC @legacy_return = dbo.p_BaseSave70
|
||||
@baseSql = @base_sql,
|
||||
@saveType = 1,
|
||||
@modid = 'hr_4011',
|
||||
@tablename = 'HR_EmpLeaveAloneTab',
|
||||
@keyfield = 'hr_ela_id',
|
||||
@keyvalue = '',
|
||||
@Operatorid = @current_employee_id,
|
||||
@operatorName = @operator_name,
|
||||
@msg = @legacy_message OUTPUT,
|
||||
@comfirmFlag = 0;
|
||||
|
||||
IF @legacy_return <> 1 OR @@TRANCOUNT < 1 OR XACT_STATE() <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_legacy_create_failed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @created_record_count BIGINT;
|
||||
DECLARE @created_identity INT;
|
||||
SELECT
|
||||
@created_record_count = COUNT_BIG(*),
|
||||
@created_identity = MAX(hr_ela_id),
|
||||
@effective_record_id = MAX(CONVERT(NVARCHAR(50), hr_ela_no))
|
||||
FROM dbo.HR_EmpLeaveAloneTab WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE hr_ela_id > @identity_before;
|
||||
IF @created_record_count <> 1
|
||||
OR @created_identity IS NULL
|
||||
OR NULLIF(LTRIM(RTRIM(@effective_record_id)), N'') IS NULL
|
||||
OR NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.HR_EmpLeaveAloneTab WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE hr_ela_id = @created_identity
|
||||
AND CONVERT(NVARCHAR(50), hr_ela_no) = @effective_record_id
|
||||
AND hr_ela_empid = CONVERT(INT, @requested_employee_number)
|
||||
AND hr_ela_employeename = CONVERT(VARCHAR(50), @employee_name)
|
||||
AND hr_ela_depid = CONVERT(VARCHAR(20), @employee_department_id)
|
||||
AND hr_ela_postid = CONVERT(VARCHAR(20), @employee_post_id)
|
||||
AND hr_ela_type = CONVERT(INT, @leave_type_number)
|
||||
AND hr_ela_billtype = CONVERT(INT, @flow_type_number)
|
||||
AND hr_ela_starttime = @start_local
|
||||
AND hr_ela_finishtime = @end_local
|
||||
AND ABS(ISNULL(hr_ela_totals, 0) - @calculated_hours) <= 0.01
|
||||
AND ABS(ISNULL(hr_ela_totals1, 0) - @calculated_days) <= 0.01
|
||||
AND hr_ela_Leavebak = CONVERT(VARCHAR(500), @reason)
|
||||
AND hr_ela_operatorid = @current_employee_id
|
||||
AND ISNULL(hr_ela_cancelFlag, 0) = 0
|
||||
)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_legacy_create_evidence_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
SET @result_code = 'leave_draft_created';
|
||||
SET @result_message = N'请假申请草稿已通过 ERP 原保存链创建。';
|
||||
|
||||
/* submit_after_save_intent is intentionally never executed here. */
|
||||
SET @submit_after_save_intent = ISNULL(@submit_after_save_intent, 0);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SET @record_id = NULLIF(LTRIM(RTRIM(@record_id)), N'');
|
||||
IF @record_id IS NULL OR LEN(@record_id) > 128
|
||||
BEGIN
|
||||
RAISERROR(N'leave_submit_record_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF DATALENGTH(CONVERT(VARCHAR(8000), @operator_name)) > 20
|
||||
BEGIN
|
||||
RAISERROR(N'leave_submit_operator_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
/* p_baseApply contains legacy self-migration branches; never let an Agent
|
||||
request reach those branches. Schema repair remains a DBA operation. */
|
||||
IF COL_LENGTH('dbo.p_systemdlltabflowtypestep', 'autoStep') IS NULL
|
||||
OR COL_LENGTH('dbo.p_systemdlltabflowtypestep', 'comfirmOper') IS NULL
|
||||
OR COL_LENGTH('dbo.p_systemNotification', 'stepCode') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N'leave_approval_schema_not_ready', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @submit_state TABLE
|
||||
(
|
||||
can_submit BIT NOT NULL,
|
||||
reason NVARCHAR(1000) NULL
|
||||
);
|
||||
INSERT INTO @submit_state(can_submit, reason)
|
||||
EXEC dbo.p_lserp_agent_workflow_read_compat100
|
||||
@workflow = 'leave',
|
||||
@action = 'can_submit',
|
||||
@module_code = @module_code,
|
||||
@account_book = @account_book,
|
||||
@subsystem_id = @subsystem_id,
|
||||
@user_id = @user_id,
|
||||
@record_id = @record_id;
|
||||
IF (SELECT COUNT_BIG(*) FROM @submit_state) <> 1
|
||||
OR NOT EXISTS (SELECT 1 FROM @submit_state WHERE can_submit = 1)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_submit_not_allowed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @submit_employee_id INT;
|
||||
DECLARE @submit_record_count BIGINT;
|
||||
SELECT
|
||||
@submit_record_count = COUNT_BIG(*),
|
||||
@submit_employee_id = MAX(hr_ela_empid),
|
||||
@effective_record_id = MAX(CONVERT(NVARCHAR(50), hr_ela_no))
|
||||
FROM dbo.HR_EmpLeaveAloneTab WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE CONVERT(NVARCHAR(32), hr_ela_id) = @record_id
|
||||
OR CONVERT(NVARCHAR(50), hr_ela_no) = @record_id;
|
||||
IF @submit_record_count <> 1
|
||||
OR @submit_employee_id <> @current_employee_id
|
||||
OR NULLIF(@effective_record_id, N'') IS NULL
|
||||
BEGIN
|
||||
RAISERROR(N'leave_submit_record_scope_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
DECLARE @apply_message NVARCHAR(4000);
|
||||
DECLARE @apply_return INT;
|
||||
SET @apply_message = N'';
|
||||
EXEC @apply_return = dbo.p_baseApply
|
||||
@typeCode = 'hr_4011',
|
||||
@billDocumentId = @effective_record_id,
|
||||
@Operatorid = @current_employee_id,
|
||||
@operatorName = @operator_name,
|
||||
@comfirmType = 1,
|
||||
@msg = @apply_message OUTPUT;
|
||||
IF @apply_return <> 1 OR @@TRANCOUNT < 1 OR XACT_STATE() <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_legacy_submit_failed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.HR_EmpLeaveAloneTab AS leave_record WITH (UPDLOCK, HOLDLOCK)
|
||||
JOIN dbo.p_baseflowOper AS flow_record WITH (UPDLOCK, HOLDLOCK)
|
||||
ON flow_record.modid = 'hr_4011'
|
||||
AND flow_record.keyvalue = leave_record.hr_ela_no
|
||||
AND flow_record.billtype = leave_record.hr_ela_billtype
|
||||
WHERE leave_record.hr_ela_no = @effective_record_id
|
||||
AND leave_record.hr_ela_empid = @current_employee_id
|
||||
)
|
||||
BEGIN
|
||||
RAISERROR(N'leave_legacy_submit_evidence_invalid', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
SET @result_code = 'leave_submitted';
|
||||
SET @result_message = N'请假申请已通过 ERP 原审批流提交。';
|
||||
END;
|
||||
|
||||
DECLARE @guid_text VARCHAR(32);
|
||||
DECLARE @transaction_evidence_id VARCHAR(128);
|
||||
DECLARE @business_audit_id VARCHAR(128);
|
||||
DECLARE @outbox_event_id VARCHAR(128);
|
||||
SET @guid_text = LOWER(REPLACE(CONVERT(VARCHAR(36), NEWID()), '-', ''));
|
||||
SET @transaction_evidence_id = 'tx-leave-' + @guid_text;
|
||||
SET @business_audit_id = 'audit-leave-' + @guid_text;
|
||||
SET @outbox_event_id = 'outbox-leave-' + @guid_text;
|
||||
|
||||
INSERT INTO dbo.p_agent_business_audit
|
||||
(
|
||||
business_audit_id, transaction_evidence_id, correlation_id,
|
||||
idempotency_id, account_book, subsystem_id, user_id,
|
||||
command_name, module_code, action_name, record_id,
|
||||
input_fingerprint
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@business_audit_id, @transaction_evidence_id, @correlation_id,
|
||||
@idempotency_id, @account_book, @subsystem_id, @user_id,
|
||||
@command_name, @module_code, @action, @effective_record_id,
|
||||
@input_fingerprint
|
||||
);
|
||||
|
||||
INSERT INTO dbo.p_agent_integration_outbox
|
||||
(
|
||||
event_id, business_audit_id, correlation_id, account_book,
|
||||
subsystem_id, module_code, action_name, record_id, status
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@outbox_event_id, @business_audit_id, @correlation_id, @account_book,
|
||||
@subsystem_id, @module_code, @action, @effective_record_id, 0
|
||||
);
|
||||
|
||||
UPDATE dbo.p_agent_command_idempotency
|
||||
SET status = 1,
|
||||
result_code = @result_code,
|
||||
record_id = @effective_record_id,
|
||||
transaction_evidence_id = @transaction_evidence_id,
|
||||
business_audit_id = @business_audit_id,
|
||||
completed_at_utc = SYSUTCDATETIME()
|
||||
WHERE id = @idempotency_id AND status = 0;
|
||||
IF @@ROWCOUNT <> 1
|
||||
BEGIN
|
||||
RAISERROR(N'leave_idempotency_completion_failed', 16, 1);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
SELECT
|
||||
CONVERT(BIT, 1) AS success,
|
||||
@result_code AS code,
|
||||
@result_message AS message,
|
||||
@effective_record_id AS record_id,
|
||||
CONVERT(BIT, 0) AS needs_ui,
|
||||
CONVERT(BIT, 0) AS idempotency_replayed,
|
||||
@idempotency_key AS applied_idempotency_key,
|
||||
@input_fingerprint AS applied_input_fingerprint,
|
||||
@transaction_evidence_id AS transaction_evidence_id,
|
||||
@business_audit_id AS business_audit_id;
|
||||
END;
|
||||
GO
|
||||
+1236
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"modules": [
|
||||
{
|
||||
"moduleCode": "REPLACE_WITH_REVIEWED_BASE_MODULE_CODE",
|
||||
"moduleKind": "base",
|
||||
"configurationFingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"nativeSaveFamily": "legacy.base-save.p-base-save",
|
||||
"nativeExecutionProfileFingerprint": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"modules": [
|
||||
{
|
||||
"moduleCode": "REPLACE_WITH_REVIEWED_MODULE_CODE",
|
||||
"moduleKind": "bill",
|
||||
"configurationFingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"nativeSaveFamily": "legacy.bill-save.p-bill-save-pr3",
|
||||
"nativeExecutionProfileFingerprint": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schemaVersion": "1.1",
|
||||
"evidenceType": "lserp_field_readonly_validation_input",
|
||||
"validationStage": "discovery",
|
||||
"approved": false,
|
||||
"approvedBy": "REPLACE_APPROVER",
|
||||
"approvedAtUtc": "2000-01-01T00:00:00Z",
|
||||
"approvalExpiresAtUtc": "2000-01-01T01:00:00Z",
|
||||
"expectedPreflightScriptSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"cli": {
|
||||
"path": "D:\\ApprovedPackages\\Lserp-AgentPet-@LSERP_PACKAGE_VERSION@-win-x64\\Host\\lserp-agent-cli.exe",
|
||||
"version": "@LSERP_PACKAGE_VERSION@",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"signerThumbprint": "0000000000000000000000000000000000000000"
|
||||
},
|
||||
"erp": {
|
||||
"processId": 1,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"session": {
|
||||
"databaseScopeFingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"userId": "REPLACE_USER_ID",
|
||||
"userName": "REPLACE_USER_NAME",
|
||||
"accountBook": "REPLACE_ACCOUNT_BOOK",
|
||||
"subSystemId": "REPLACE_SUBSYSTEM_ID",
|
||||
"expectedIsAdministrator": false
|
||||
},
|
||||
"rollout": {
|
||||
"customerId": "REPLACE_CUSTOMER_ID",
|
||||
"policySha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"moduleBindings": [
|
||||
{
|
||||
"role": "purchase",
|
||||
"moduleCode": "REPLACE_PURCHASE_MODULE"
|
||||
}
|
||||
],
|
||||
"requirements": {
|
||||
"purchaseWorkflow": false,
|
||||
"leaveWorkflow": false,
|
||||
"diagnosisWorkflow": false
|
||||
},
|
||||
"bridgeTimeoutMilliseconds": 180000,
|
||||
"databaseCredentialsIncluded": false,
|
||||
"readOnlyEvidenceOutputPath": "D:\\Acceptance\\Evidence\\purchase-readonly-session-preflight-new.json"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"auditedAtUtc": "2026-08-13T19:31:24+00:00",
|
||||
"asset": {
|
||||
"id": "guga",
|
||||
"displayName": "咕嘎",
|
||||
"ownerHandle": "circus",
|
||||
"ownerName": "CIRCUS",
|
||||
"uploadedAtUtc": "2026-05-02T11:26:41.758654+00:00",
|
||||
"shareUrl": "https://codex-pets.net/share/guga",
|
||||
"shareDataUrl": "https://codex-pets.net/api/pets/guga/share-data",
|
||||
"downloadUrl": "https://codex-pets.net/api/pets/guga/download?v=1777721201758",
|
||||
"packageSha256": "3ebd971ba59a0c988a6be0924669b4c5db9234bcc5d17d506e34eba332e6021f",
|
||||
"packageSizeBytes": 1946012,
|
||||
"manifestSha256": "f9f715811c26ca610764a7698e28f2e182882f097f4c60a3f00a79dd7530bd20",
|
||||
"spriteSha256": "1b61ea2af98717b9ebe55beb4c6b820b89e9c42d4fdfeca21cf63ed3ad4e38da",
|
||||
"spriteSizeBytes": 1945586,
|
||||
"atlasSize": "1536x1872",
|
||||
"licenseMetadataPresent": false,
|
||||
"licenseFilePresent": false
|
||||
},
|
||||
"installer": {
|
||||
"packageName": "codex-pets",
|
||||
"version": "0.3.0",
|
||||
"registryUrl": "https://registry.npmjs.org/codex-pets",
|
||||
"tarballUrl": "https://registry.npmjs.org/codex-pets/-/codex-pets-0.3.0.tgz",
|
||||
"tarballSha1": "82e41349ae63eb9e63099f2e06a56468182e2c90",
|
||||
"tarballSha256": "9ec8bf1ea09e6d8fdc17b33a594a178a9b20bd3dc6decbb22973758394c9c1c7",
|
||||
"npmIntegrity": "sha512-b7PjV0phEK7jn0rnyXzh3LMIsAdqSUf75mCdSxZEyIuFScCxwOeUZoDxnWj97rfg4ihk6XLaKvg6fgWD+CWcAQ==",
|
||||
"declaredLicense": "MIT",
|
||||
"repositoryDeclared": false,
|
||||
"defaultApiBase": "https://codex-pets.net",
|
||||
"installRoot": "$CODEX_HOME/pets/{pet-id}",
|
||||
"writtenFiles": [
|
||||
"pet.json",
|
||||
"spritesheet.webp"
|
||||
],
|
||||
"assetDigestVerification": false,
|
||||
"assetSignatureVerification": false,
|
||||
"assetLicenseVerification": false
|
||||
},
|
||||
"serviceSource": {
|
||||
"repository": "https://github.com/portons/codex-pet-share",
|
||||
"commit": "22725091da2787e8e525c9289cb7826a34be4950",
|
||||
"softwareLicense": "MIT",
|
||||
"licenseSha256": "13e779572adacb503b7e7a0c676571fcd86114a73f6aa000412c24a9a06a97d3",
|
||||
"termsSourceSha256": "70ad12414864566b8cd469a7d2ca39fe60050686cecacc126ff1aca587f790bb",
|
||||
"termsEffectiveDate": "2026-05-09",
|
||||
"uploadTermsScope": "public-sharing-through-service"
|
||||
},
|
||||
"commercialDecision": {
|
||||
"status": "external-license-required",
|
||||
"code": "guga_commercial_license_missing",
|
||||
"reason": "The installer and service source are MIT-licensed software, but the downloaded guga asset has no asset-specific license metadata or license file and the service terms do not grant commercial redistribution rights.",
|
||||
"requiredEvidence": [
|
||||
"rights-holder-identity",
|
||||
"commercial-product-use",
|
||||
"customer-deployment-and-copying",
|
||||
"product-display",
|
||||
"territory-and-term",
|
||||
"asset-sha256-binding",
|
||||
"authorized-legal-review"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"packageType": "workflow_write_uat_case_catalog",
|
||||
"safety": {
|
||||
"productionUseProhibited": true,
|
||||
"automaticDatabaseOrConfigurationChanges": false,
|
||||
"approvedRestorePointRequired": true,
|
||||
"containsCredentials": false,
|
||||
"containsBusinessIdentifiers": false,
|
||||
"executableInstructionsIncluded": false
|
||||
},
|
||||
"workflows": [
|
||||
{
|
||||
"workflow": "purchase",
|
||||
"caseCount": 13,
|
||||
"cases": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"caseCode": "purchase_unique_match_commit",
|
||||
"title": "唯一来源匹配并创建采购业务单据",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_document_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "positive",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": true,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["business_fixture_owner", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_unique_open_source",
|
||||
"preconditions": [
|
||||
"可恢复非生产库中存在唯一开放来源,供应商、币种、物料、单位、数量、单价、税率和行级范围均已审批。",
|
||||
"必须上传至少一份脱敏电子 PDF 发票或明细;该 PDF 必须由随包 PDFium 安全渲染器逐页处理并经 MiniMax 视觉识别,来源、分页和提取摘要及 pdfium_minimax_pages_v1 契约均由可信上传链生成。"
|
||||
],
|
||||
"operatorSteps": [
|
||||
"记录创建前业务行、命令审计和来源附件审计基线。",
|
||||
"生成预览,逐项核对来源行、金额、税额、附件数量和集合摘要后完成桌宠及 ERP 原生双重确认。",
|
||||
"同次采集必须同时生成 purchase_audit_correlated 证据。"
|
||||
],
|
||||
"dbaReadOnlyChecks": [
|
||||
"确认只新增一张目标业务单据,事务证据、业务审计和命令关联 ID 完整。",
|
||||
"确认来源文件摘要、提取摘要和 pdfium_minimax_pages_v1 预处理契约同时进入 XML v3 写入载荷及同一审计链。"
|
||||
],
|
||||
"cleanupSteps": ["保留该记录作为重放、冲突和审计关联用例的受控依赖,完成整组后再按恢复方案清理。"],
|
||||
"retryPolicy": "single_success_then_relationship_cases_only"
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"caseCode": "purchase_ambiguous_match_blocked",
|
||||
"title": "两个来源同等匹配时阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "purchase_match_invalid",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "business_fixture_owner",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_two_equal_sources",
|
||||
"preconditions": ["准备两个对同一发票行具有相同确定性匹配条件的开放来源,且均位于当前用户已审批范围。"],
|
||||
"operatorSteps": ["仅生成计划,确认返回候选差异且 executionAllowed=false,不进入确认或执行。"],
|
||||
"dbaReadOnlyChecks": ["确认目标业务表零新增,两个来源均未被占用。"],
|
||||
"cleanupSteps": ["按测试数据台账移除或恢复第二个歧义来源。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"caseCode": "purchase_overallocation_blocked",
|
||||
"title": "发票累计数量超过开放来源余量时阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "purchase_match_invalid",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "business_fixture_owner",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_aggregate_overallocation",
|
||||
"preconditions": ["准备多行发票输入,使相同来源明细的累计数量严格大于只读查询得到的剩余数量。"],
|
||||
"operatorSteps": ["仅生成计划,核对稳定阻断码并确认没有可执行计划。"],
|
||||
"dbaReadOnlyChecks": ["确认来源余量和目标业务表均未变化。"],
|
||||
"cleanupSteps": ["无需数据库清理;销毁本场景脱敏输入。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"caseCode": "purchase_permission_denied",
|
||||
"title": "ERP 命令权限不足时在计划前阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "command_access_denied",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "erp_security_admin",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_command_permission_removed",
|
||||
"preconditions": ["使用专用 UAT 用户,按客户变更流程临时移除采购创建命令所需的原 ERP 权限,不改变其他用例账号。"],
|
||||
"operatorSteps": ["尝试生成计划,确认能力不可见或返回 command_access_denied,且不出现确认按钮。"],
|
||||
"dbaReadOnlyChecks": ["确认零业务变更并存在一条脱敏权限拒绝审计。"],
|
||||
"cleanupSteps": ["由 ERP 安全管理员恢复并复核原权限,重新登录后再继续其他用例。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"caseCode": "purchase_database_permission_recheck_denied",
|
||||
"title": "数据库写权限复核拒绝并回滚",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_write_permission_denied",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["erp_security_admin", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_database_write_grant_denied",
|
||||
"preconditions": ["命令权限允许生成有效预览,但客户批准的数据库写权限复核行对当前用户明确拒绝或已失效。"],
|
||||
"operatorSteps": ["完成双重确认并执行,确认数据库过程返回固定权限拒绝码。"],
|
||||
"dbaReadOnlyChecks": ["确认事务零业务变更、幂等结果未记为成功,计划和执行审计均存在。"],
|
||||
"cleanupSteps": ["由 DBA 恢复经过审批的 UAT 写权限复核行并重新验证哈希。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"caseCode": "purchase_currency_field_missing_blocked",
|
||||
"title": "目标币种字段未配置时执行阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_currency_field_not_configured",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "low_code_config_admin",
|
||||
"supportingRoles": ["customer_dba", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_currency_target_field_unavailable",
|
||||
"preconditions": ["在可恢复 UAT 配置中使目标单据币种字段未暴露或未映射,同时保留可生成预览的其他字段。"],
|
||||
"operatorSteps": ["完成双重确认并执行,确认固定币种字段配置错误,不允许降级为本位币。"],
|
||||
"dbaReadOnlyChecks": ["确认目标业务表零新增且事务完整回滚。"],
|
||||
"cleanupSteps": ["恢复已审核字段映射并重新生成画像及相关哈希后再继续。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"caseCode": "purchase_currency_crosswalk_unapproved_blocked",
|
||||
"title": "币种换算关系未审批时阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_currency_crosswalk_not_approved",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["finance_fixture_owner", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_currency_crosswalk_expired",
|
||||
"preconditions": ["使用需要换算的测试币种,并使对应 UAT 换算审批不存在、过期或审批哈希不匹配。"],
|
||||
"operatorSteps": ["完成双重确认并执行,确认未审批换算绝不自动选取汇率。"],
|
||||
"dbaReadOnlyChecks": ["确认目标业务表和来源占用均为零变化。"],
|
||||
"cleanupSteps": ["恢复经财务批准的换算关系及审批哈希。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"caseCode": "purchase_row_scope_denied",
|
||||
"title": "组织部门采购员行级范围拒绝",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_row_scope_denied",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["erp_security_admin", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_row_scope_not_approved",
|
||||
"preconditions": ["目标来源属于未对当前 UAT 用户审批的组织、部门、采购员精确元组。"],
|
||||
"operatorSteps": ["执行已确认计划,确认行级范围复核拒绝且响应不泄露未授权来源标识。"],
|
||||
"dbaReadOnlyChecks": ["确认未授权来源未被占用、目标业务表零新增,权限拒绝审计不含业务原值。"],
|
||||
"cleanupSteps": ["恢复该用户原行级范围审批状态。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"caseCode": "purchase_runtime_recheck_blocked",
|
||||
"title": "预览后采购来源变化时执行复核阻断",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_source_changed",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "business_fixture_owner",
|
||||
"supportingRoles": ["customer_dba", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_source_changed_after_plan",
|
||||
"preconditions": ["准备可生成唯一有效预览的来源,并预先批准一项可恢复的计划后变化动作。"],
|
||||
"operatorSteps": ["使用 PauseAfterPlanForOperatorStaging 生成并固定计划。", "暂停期间由授权人员改变来源单号、余量、单价、税率、汇率或状态之一,再输入精确阶段确认口令继续。"],
|
||||
"dbaReadOnlyChecks": ["确认执行返回 purchase_source_changed,目标业务表零新增且来源只保留已批准的测试变化。"],
|
||||
"cleanupSteps": ["按恢复点还原来源测试行并由第二人只读复核。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"caseCode": "purchase_transaction_rollback",
|
||||
"title": "原 ERP 保存链失败时事务完整回滚",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_legacy_create_failed",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["legacy_erp_owner", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_controlled_save_failure",
|
||||
"preconditions": ["客户 DBA 已审批一个仅在可恢复 UAT 生效、可确定触发原 ERP 保存失败的测试夹具。"],
|
||||
"operatorSteps": ["记录事务前基线,完成双重确认并触发受控保存失败。"],
|
||||
"dbaReadOnlyChecks": ["确认主表、明细、来源占用、业务审计、来源附件和成功幂等结果均未部分提交;失败命令审计仍存在。"],
|
||||
"cleanupSteps": ["立即停用受控失败夹具,并复核后续正常事务不受影响。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"caseCode": "purchase_idempotency_replay",
|
||||
"title": "同键同输入重放返回原结果且不重复创建",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "purchase_document_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": true,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_replay_commit_relationship",
|
||||
"preconditions": ["唯一提交用例已使用脱敏电子 PDF 成功,保留其精确业务输入、附件集合、pdfium_minimax_pages_v1 契约和受控幂等键;重新取得有效解析凭证并生成新计划。"],
|
||||
"operatorSteps": ["以与提交用例相同的业务输入和幂等键执行新计划,完成双重确认。"],
|
||||
"dbaReadOnlyChecks": ["确认返回原记录、事务和业务审计关系,replayed=true,业务变更数为零且没有第二张单据。"],
|
||||
"cleanupSteps": ["继续保留依赖记录直至幂等冲突用例完成。"],
|
||||
"retryPolicy": "dependency_key_relationship_required"
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"caseCode": "purchase_idempotency_conflict",
|
||||
"title": "同键不同输入必须拒绝",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "idempotency_key_conflict",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "purchase_conflict_commit_relationship",
|
||||
"preconditions": ["唯一提交用例已成功;准备一个业务输入指纹确定不同但仍可生成有效计划的脱敏输入。"],
|
||||
"operatorSteps": ["使用提交用例的同一幂等键执行不同输入的新计划。"],
|
||||
"dbaReadOnlyChecks": ["确认返回 idempotency_key_conflict,零业务变化且原成功幂等结果未被覆盖。"],
|
||||
"cleanupSteps": ["按整组恢复方案清理采购 UAT 记录、来源和临时审批。"],
|
||||
"retryPolicy": "dependency_key_relationship_required"
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"caseCode": "purchase_audit_correlated",
|
||||
"title": "采购提交的审计链关联证明",
|
||||
"commandName": "purchase.invoice.create",
|
||||
"captureMode": "derived_audit",
|
||||
"expectedResultCode": "purchase_document_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "inherited_required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": true,
|
||||
"primaryRole": "dba_readonly_reviewer",
|
||||
"supportingRoles": ["qa_operator"],
|
||||
"fixtureCode": "purchase_audit_derived_from_commit",
|
||||
"preconditions": ["唯一提交采集必须已在同一次执行中请求关联审计输出。"],
|
||||
"operatorSteps": ["不得再次执行数据库写操作;只复核由提交响应离线派生的独立审计用例令牌绑定。"],
|
||||
"dbaReadOnlyChecks": ["确认记录、事务、业务审计、幂等键、计划、附件集合和 pdfium_minimax_pages_v1 契约均与唯一提交一致。"],
|
||||
"cleanupSteps": ["无需单独清理;随采购整组证据归档。"],
|
||||
"retryPolicy": "derived_with_dependency_only"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"workflow": "leave",
|
||||
"caseCount": 19,
|
||||
"cases": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"caseCode": "leave_natural_language_resolution",
|
||||
"title": "单日自然语言请假唯一解析",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_intent_resolved",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["hr_fixture_owner", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_single_day_unique_resolution",
|
||||
"preconditions": ["当前 ERP 用户已绑定唯一员工、唯一可用假别、唯一有效流程类别和已知员工日历;选择测试时尚未过去的本周星期或下周星期。"],
|
||||
"operatorSteps": ["使用包含本人、本周X或下周X、明确上午下午全天和原因的自然语言,仅生成解析计划;确认 AstrBot 原样传递 dateExpression。", "核对星期由 ERP 当前本地时间按周一为周首解析、resolvedCommand=hr.leave.create、工时来自员工日历且 executionAllowed=false。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化并保留一条解析审计。"],
|
||||
"cleanupSteps": ["无需数据库清理;销毁脱敏自然语言输入。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"caseCode": "leave_multi_day_calendar_resolution",
|
||||
"title": "跨日请假按员工日历解析",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_intent_resolved",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "hr_fixture_owner",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_multi_day_known_calendar_hours",
|
||||
"preconditions": ["准备两个未来月日、两端明确时段和经 HR 只读核对的预期总工时;允许跨年但不得超过未来 366 天。"],
|
||||
"operatorSteps": ["使用“M月D日上午到M月D日下午”形式输入跨日自然语言及明确工时断言,仅生成解析计划;核对未写年份的月日由 ERP 本地时间解析到尚未过去的最近日期,并核对起止本地时间和总工时。"],
|
||||
"dbaReadOnlyChecks": ["确认解析使用当前员工日历且零请假记录变化。"],
|
||||
"cleanupSteps": ["无需数据库清理。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"caseCode": "leave_resolution_proof_bypass_blocked",
|
||||
"title": "绕过解析或伪造解析凭证时阻断",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_resolution_invalid",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_forged_or_missing_resolution_proof",
|
||||
"preconditions": ["准备缺少、伪造、过期或与当前会话范围不一致的创建输入,不使用服务器自动续接结果。"],
|
||||
"operatorSteps": ["直接请求创建计划,确认在读取请假业务数据和生成确认前返回 leave_resolution_invalid。"],
|
||||
"dbaReadOnlyChecks": ["确认零业务查询副作用、零请假记录变化且拒绝审计不含凭证明文。"],
|
||||
"cleanupSteps": ["销毁伪造输入,禁止复制任何真实 resolutionProof。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"caseCode": "leave_ambiguous_type_blocked",
|
||||
"title": "假别名称多候选时阻断",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_resolution_invalid",
|
||||
"expectedIssueCode": "leave_type_ambiguous",
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "hr_fixture_owner",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_two_enabled_type_aliases",
|
||||
"preconditions": ["在可恢复 UAT 配置中准备两个对相同用户输入同等有效的启用假别或别名。"],
|
||||
"operatorSteps": ["仅生成解析计划,确认返回候选和 leave_type_ambiguous,不自动选择。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化。"],
|
||||
"cleanupSteps": ["恢复假别别名唯一性并由 HR 复核。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"caseCode": "leave_ambiguous_flow_type_blocked",
|
||||
"title": "流程类别多候选时阻断",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_resolution_invalid",
|
||||
"expectedIssueCode": "leave_flow_type_ambiguous",
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "low_code_config_admin",
|
||||
"supportingRoles": ["hr_fixture_owner", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_two_active_flow_types",
|
||||
"preconditions": ["准备两个同时符合当前员工、假别、岗位和工时条件的有效流程类别。"],
|
||||
"operatorSteps": ["仅生成解析计划,确认返回候选和 leave_flow_type_ambiguous,不按名称或顺序选第一项。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化。"],
|
||||
"cleanupSteps": ["恢复流程类别唯一性并重新签署配置画像。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"caseCode": "leave_time_segment_required_blocked",
|
||||
"title": "缺少上午下午全天时要求补充",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_resolution_invalid",
|
||||
"expectedIssueCode": "leave_time_segment_required",
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_missing_day_part",
|
||||
"preconditions": ["使用有效日期但故意不说明上午、下午或全天。"],
|
||||
"operatorSteps": ["仅生成解析计划,确认系统追问且不补全时段。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化。"],
|
||||
"cleanupSteps": ["无需数据库清理。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"caseCode": "leave_local_time_zone_rejected",
|
||||
"title": "带时区的本地请假时间输入被 Schema 拒绝",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "input_schema_violation",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_utc_or_offset_time_input",
|
||||
"preconditions": ["准备 startLocal 或 endLocal 含 Z、UTC 或显式偏移的输入。"],
|
||||
"operatorSteps": ["请求创建计划,确认在业务查询前返回 input_schema_violation。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化。"],
|
||||
"cleanupSteps": ["销毁无效测试输入。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"caseCode": "leave_other_employee_denied",
|
||||
"title": "无代申请权限时拒绝其他员工",
|
||||
"commandName": "hr.leave.resolve",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_resolution_invalid",
|
||||
"expectedIssueCode": "leave_employee_reference_unsupported",
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "erp_security_admin",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_other_employee_without_delegation",
|
||||
"preconditions": ["当前 UAT 用户没有代申请权限,并准备一个不属于当前用户的脱敏员工引用。"],
|
||||
"operatorSteps": ["仅生成解析计划,确认返回 leave_employee_reference_unsupported 且不泄露员工资料。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化和零越权员工业务读取。"],
|
||||
"cleanupSteps": ["无需权限变更或数据库清理。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"caseCode": "leave_permission_denied",
|
||||
"title": "请假创建命令权限不足时阻断",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "command_access_denied",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "erp_security_admin",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_command_permission_removed",
|
||||
"preconditions": ["使用专用 UAT 用户,临时移除请假创建命令所需的原 ERP 权限。"],
|
||||
"operatorSteps": ["尝试创建计划,确认能力不可见或返回 command_access_denied,且没有确认按钮。"],
|
||||
"dbaReadOnlyChecks": ["确认零业务变更并存在权限拒绝审计。"],
|
||||
"cleanupSteps": ["恢复原权限并重新登录 ERP。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"caseCode": "leave_database_permission_recheck_denied",
|
||||
"title": "数据库请假写权限复核拒绝并回滚",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_write_permission_denied",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["erp_security_admin", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_database_write_grant_denied",
|
||||
"preconditions": ["命令权限和解析允许有效预览,但数据库写权限复核对当前用户明确拒绝或已失效。"],
|
||||
"operatorSteps": ["完成双重确认并执行,确认返回 leave_write_permission_denied。"],
|
||||
"dbaReadOnlyChecks": ["确认零请假记录变化、幂等结果未记为成功,计划和执行审计均存在。"],
|
||||
"cleanupSteps": ["恢复经过审批的 UAT 写权限复核行。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"caseCode": "leave_create_draft_commit",
|
||||
"title": "创建请假草稿且不自动提交",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_draft_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "positive",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["hr_fixture_owner", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_valid_draft",
|
||||
"preconditions": ["当前员工、假别、流程、日历和时间范围唯一有效且无重叠。"],
|
||||
"operatorSteps": ["核对解析后的本地时段、工时、部门岗位和原因,完成双重确认创建草稿。", "同次采集必须生成 leave_audit_correlated;即使用户表达提交意图,也只接受新的后续提交计划。"],
|
||||
"dbaReadOnlyChecks": ["确认只新增一条草稿、提交计数为零,事务和业务审计完整。"],
|
||||
"cleanupSteps": ["保留草稿作为提交、重放、冲突和审计关联用例的依赖。"],
|
||||
"retryPolicy": "single_success_then_relationship_cases_only"
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"caseCode": "leave_submit_separate_confirmation",
|
||||
"title": "请假提交必须经过第二次独立确认",
|
||||
"commandName": "hr.leave.submit",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_submitted",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "positive",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["hr_fixture_owner", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_submit_created_draft",
|
||||
"preconditions": ["草稿创建用例已成功,记录仍为可提交状态且没有自动提交。"],
|
||||
"operatorSteps": ["使用服务器返回的新提交计划、新幂等键和第二次桌宠及 ERP 原生确认完成提交。"],
|
||||
"dbaReadOnlyChecks": ["确认沿用同一草稿记录,但计划、输入指纹、事务、审计和幂等键均与创建不同,提交计数恰好增加一次。"],
|
||||
"cleanupSteps": ["保留记录直至整组关系验证完成。"],
|
||||
"retryPolicy": "dependency_key_relationship_required"
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"caseCode": "leave_overlap_blocked",
|
||||
"title": "与已有请假时间重叠时阻断",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "plan_only",
|
||||
"expectedResultCode": "leave_request_invalid",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "prohibited",
|
||||
"minimumAuditEventCount": 1,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "hr_fixture_owner",
|
||||
"supportingRoles": ["qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_existing_overlap",
|
||||
"preconditions": ["准备一条当前员工有效、未删除且与测试时段重叠的请假记录。"],
|
||||
"operatorSteps": ["仅生成创建计划,确认返回 leave_request_invalid 且无可执行计划。"],
|
||||
"dbaReadOnlyChecks": ["确认既有记录未变化且没有新增草稿。"],
|
||||
"cleanupSteps": ["按 HR 测试数据台账恢复或移除重叠记录。"],
|
||||
"retryPolicy": "new_capture_allowed_while_authorization_active"
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"caseCode": "leave_stale_flow_type_blocked",
|
||||
"title": "预览后流程类别失效时阻断",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_request_changed",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "low_code_config_admin",
|
||||
"supportingRoles": ["hr_fixture_owner", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_flow_disabled_after_plan",
|
||||
"preconditions": ["准备可生成有效创建计划的唯一流程类别,并批准可恢复的计划后停用动作。"],
|
||||
"operatorSteps": ["使用 PauseAfterPlanForOperatorStaging 固定计划。", "暂停期间由授权配置人员停用或替换流程类别,再输入精确阶段确认口令继续。"],
|
||||
"dbaReadOnlyChecks": ["确认执行返回 leave_request_changed,零请假记录变化。"],
|
||||
"cleanupSteps": ["恢复流程配置并重新签署客户画像及相关哈希。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 15,
|
||||
"caseCode": "leave_runtime_recheck_blocked",
|
||||
"title": "预览后日历工时或规则变化时阻断",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_request_changed",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "hr_fixture_owner",
|
||||
"supportingRoles": ["customer_dba", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_calendar_changed_after_plan",
|
||||
"preconditions": ["准备可生成有效创建计划的员工日历,并批准一项最小且可恢复的计划后日历或规则变化。"],
|
||||
"operatorSteps": ["使用 PauseAfterPlanForOperatorStaging 固定计划。", "暂停期间改变测试时段日历工时或其他绑定规则,再输入精确阶段确认口令继续。"],
|
||||
"dbaReadOnlyChecks": ["确认即使仅变化最小工时也返回 leave_request_changed,零请假记录变化。"],
|
||||
"cleanupSteps": ["恢复员工日历和规则并由 HR 第二人复核。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 16,
|
||||
"caseCode": "leave_transaction_rollback",
|
||||
"title": "请假原保存链失败时事务完整回滚",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_legacy_create_failed",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "customer_dba",
|
||||
"supportingRoles": ["legacy_erp_owner", "qa_operator", "dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_controlled_save_failure",
|
||||
"preconditions": ["客户 DBA 已审批一个仅在可恢复 UAT 生效、可确定触发请假保存失败的测试夹具。"],
|
||||
"operatorSteps": ["记录事务前基线,完成双重确认并触发受控保存失败。"],
|
||||
"dbaReadOnlyChecks": ["确认请假主记录、派生字段、业务审计和成功幂等结果均未部分提交;失败命令审计仍存在。"],
|
||||
"cleanupSteps": ["立即停用受控失败夹具并复测正常事务。"],
|
||||
"retryPolicy": "new_plan_and_new_key_required"
|
||||
},
|
||||
{
|
||||
"sequence": 17,
|
||||
"caseCode": "leave_idempotency_replay",
|
||||
"title": "请假同键同输入重放不重复创建",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "leave_draft_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_replay_draft_relationship",
|
||||
"preconditions": ["草稿创建用例已成功,保留相同业务输入和幂等键;重新解析取得有效凭证并生成新计划。"],
|
||||
"operatorSteps": ["以相同业务输入和幂等键执行新计划并完成双重确认。"],
|
||||
"dbaReadOnlyChecks": ["确认返回原草稿关系、replayed=true、业务变更数为零且没有第二条草稿。"],
|
||||
"cleanupSteps": ["继续保留依赖记录直至冲突用例完成。"],
|
||||
"retryPolicy": "dependency_key_relationship_required"
|
||||
},
|
||||
{
|
||||
"sequence": 18,
|
||||
"caseCode": "leave_idempotency_conflict",
|
||||
"title": "请假同键不同输入必须拒绝",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "execute",
|
||||
"expectedResultCode": "idempotency_key_conflict",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "qa_operator",
|
||||
"supportingRoles": ["dba_readonly_reviewer"],
|
||||
"fixtureCode": "leave_conflict_draft_relationship",
|
||||
"preconditions": ["草稿创建用例已成功;准备一个业务输入指纹确定不同但仍能生成有效计划的脱敏请假输入。"],
|
||||
"operatorSteps": ["使用草稿创建用例的同一幂等键执行不同输入的新计划。"],
|
||||
"dbaReadOnlyChecks": ["确认返回 idempotency_key_conflict,零业务变化且原成功幂等结果未被覆盖。"],
|
||||
"cleanupSteps": ["按整组恢复方案清理请假 UAT 记录、日历、流程和临时权限。"],
|
||||
"retryPolicy": "dependency_key_relationship_required"
|
||||
},
|
||||
{
|
||||
"sequence": 19,
|
||||
"caseCode": "leave_audit_correlated",
|
||||
"title": "请假草稿创建的审计链关联证明",
|
||||
"commandName": "hr.leave.create",
|
||||
"captureMode": "derived_audit",
|
||||
"expectedResultCode": "leave_draft_created",
|
||||
"expectedIssueCode": null,
|
||||
"expectedMutationPolicy": "zero",
|
||||
"nativeConfirmationPolicy": "inherited_required",
|
||||
"minimumAuditEventCount": 2,
|
||||
"sourceDocumentProofRequired": false,
|
||||
"primaryRole": "dba_readonly_reviewer",
|
||||
"supportingRoles": ["qa_operator"],
|
||||
"fixtureCode": "leave_audit_derived_from_draft",
|
||||
"preconditions": ["草稿创建采集必须已在同一次执行中请求关联审计输出。"],
|
||||
"operatorSteps": ["不得再次执行数据库写操作;只复核由草稿创建响应离线派生的独立审计用例令牌绑定。"],
|
||||
"dbaReadOnlyChecks": ["确认记录、事务、业务审计、幂等键和计划均与草稿创建用例一致。"],
|
||||
"cleanupSteps": ["无需单独清理;随请假整组证据归档。"],
|
||||
"retryPolicy": "derived_with_dependency_only"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user