Merge wilm into wj
Brings in wilm commits: - Render script surfaces correctly in dark mode - Stop scripts reporting success after doing nothing - Correct two gate commands that pass without running - Gate the script library on the grant that pushes scripts - Let the policy list edit the rows the API accepts - Cap plugin revision ids at the 32 chars MySQL stores - Split shipped monitor bundle into per-monitor policies Resolved conflict in agent ShopDbEndpointAgent.csproj by merging: - wilm 1.14.0 script verdict and output capture features - wj 1.13.1-1.13.5 tray coordination releases - Bumped to 1.14.1 for coordinated agent+tray release
This commit is contained in:
15
.github/copilot-instructions.md
vendored
15
.github/copilot-instructions.md
vendored
@@ -1,4 +1,4 @@
|
||||
# Copilot instructions for shopdb-flask
|
||||
, # Copilot instructions for shopdb-flask
|
||||
|
||||
Follow these when suggesting code. They are enforced by a naming/style hook and
|
||||
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
@@ -7,11 +7,12 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
## General Guidelines
|
||||
- Finish the in-flight task before starting new workstreams.
|
||||
- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies.
|
||||
- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F <file>`. When committing in PowerShell, never pass multi-line commit messages inline (no here-strings like @'...'@ or multiple -m flags). PowerShell mangles them and git errors with "did not match any file(s) known to git". Instead, write the commit message to a temp file in the WORKSPACE ROOT (e.g. `commitmsg.tmp`), run `git commit -F commitmsg.tmp`, then delete the file. Do NOT place the temp file inside .git/ - git prunes stray files there and the file disappears before commit runs.
|
||||
- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F <file>`. When committing in PowerShell, always write the message to a temp file in the WORKSPACE ROOT (e.g. `commitmsg.tmp`), run `git commit -F commitmsg.tmp`, then delete the file. Passing a multi-line here-string to `git commit -m` silently no-ops: it reports success but does not create the commit. Always verify with `git log` after committing. Do NOT place the temp file inside .git/ - git prunes stray files there and the file disappears before commit runs.
|
||||
- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined.
|
||||
- Long-running commands (pytest suites, npm builds, container rolls) must be redirected to a log file under `devnotes/` (e.g. `devnotes/_pytest.log`) and launched non-blocking; never run as a blocking foreground command. Do not poll or tail the output; the user watches the file and will say when it is done.
|
||||
- Before taking any action that modifies code or files, warn the user first if a test run or build may be in progress, so discussion about the project can continue without altering files underneath a running test.
|
||||
- Always invoke shell scripts with the explicit Git Bash path: `& "C:\Program Files\Git\bin\bash.exe" scripts/<script>.sh` to avoid issues with WSL resolving bare `bash` in PowerShell.
|
||||
- Do not present findings as narrow multiple-choice questions when the user is catching up on context; lay out the decision and its evidence in prose instead.
|
||||
|
||||
## Installer Logic
|
||||
- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers.
|
||||
@@ -98,9 +99,15 @@ Run the three gates (CI runs the same):
|
||||
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
cd frontend && npx vitest run && npm run build && cd ..
|
||||
bash scripts/check-naming-and-style.sh
|
||||
cd frontend && npm test && npm run build && cd ..
|
||||
& "C:\Program Files\Git\bin\bash.exe" scripts/check-naming-and-style.sh
|
||||
```
|
||||
|
||||
`npm test`, NOT `npx vitest run`: the `pretest` hook runs `stage-frontend.mjs`,
|
||||
so bare vitest never stages plugin frontends and silently skips their specs.
|
||||
Call the style script through the explicit Git Bash path - bare `bash` resolves
|
||||
to WSL on Windows dev boxes, fails to find `/bin/bash`, and reads as a clean
|
||||
run with no findings.
|
||||
|
||||
Commits: short present-tense subject, body says WHY. No AI/tool attribution in
|
||||
commit messages, code comments, or docs.
|
||||
|
||||
@@ -901,6 +901,7 @@ public sealed class CommandExecutor
|
||||
|
||||
var runInUser = string.Equals(
|
||||
script.RunContext, "user", StringComparison.OrdinalIgnoreCase);
|
||||
ApplyErrorMode(script, tempPath, name);
|
||||
return runInUser
|
||||
? RunScriptInUserSession(name, script, tempPath)
|
||||
: await RunScriptAsSystem(name, script, tempPath, token);
|
||||
@@ -933,18 +934,203 @@ public sealed class CommandExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort. A missing transcript directory must not stop the script from
|
||||
// running: the capture is diagnostics, and losing diagnostics is a smaller
|
||||
// failure than refusing to do the work that was asked for.
|
||||
private void TryEnsureTranscriptDirectory(string transcriptPath, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(transcriptPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory!);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error,
|
||||
"run_script {Name}: could not prepare the output transcript folder; "
|
||||
+ "the script will still run but its output will not be captured", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the transcript back and delete it. Deleted whether or not the read
|
||||
// worked: it sits in the operator's own profile where they could read it,
|
||||
// and a script's output is not theirs to keep lying around after the agent
|
||||
// has reported it.
|
||||
private string ReadAndClearTranscript(string transcriptPath, string name)
|
||||
{
|
||||
var output = "";
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
output = CombineScriptOutput(File.ReadAllText(transcriptPath), null);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error, "run_script {Name}: could not read the output transcript", name);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (File.Exists(transcriptPath))
|
||||
File.Delete(transcriptPath);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(
|
||||
error, "run_script {Name}: transcript cleanup failed", name);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Cap on captured script output. Two different limits are at work: the
|
||||
// endpoint log should stay readable, and the server payload rides an HTTP
|
||||
// POST into a MEDIUMTEXT column. A runaway loop printing megabytes must not
|
||||
// turn a failed script into a failed REPORT - losing the verdict entirely is
|
||||
// worse than losing the tail of the evidence. Head is kept rather than tail
|
||||
// because the FIRST error is the one that explains the failure; under strict
|
||||
// mode nothing after it ran anyway.
|
||||
private const int MaxCapturedOutputChars = 8000;
|
||||
|
||||
// Merge the two streams into one operator-readable blob. stderr is labelled
|
||||
// rather than concatenated blind, because "which stream said this" is the
|
||||
// difference between a warning a script prints and the error that killed it.
|
||||
private static string CombineScriptOutput(string? stdout, string? stderr)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
parts.Add(stdout!.TrimEnd());
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
parts.Add("[stderr] " + stderr!.TrimEnd());
|
||||
var combined = string.Join(Environment.NewLine, parts);
|
||||
if (combined.Length <= MaxCapturedOutputChars)
|
||||
return combined;
|
||||
// Say so explicitly. Silently truncated evidence reads as a script that
|
||||
// stopped talking, which is a different diagnosis than one that was cut.
|
||||
return combined.Substring(0, MaxCapturedOutputChars)
|
||||
+ Environment.NewLine
|
||||
+ $"[truncated after {MaxCapturedOutputChars} characters]";
|
||||
}
|
||||
|
||||
// The endpoint log gets the output ALWAYS, success or failure. This is the
|
||||
// copy that stays on the box: it never leaves the endpoint, so it costs no
|
||||
// exposure, and it is the only record when a script reports success while
|
||||
// doing nothing useful. The server copy is failure-only (see ScriptDetail).
|
||||
private void LogScriptOutput(string name, string output, int? exitCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"run_script {Name} produced no output (exit {Exit})",
|
||||
name, exitCode?.ToString() ?? "unknown");
|
||||
return;
|
||||
}
|
||||
logger.LogInformation(
|
||||
"run_script {Name} output (exit {Exit}):{NewLine}{Output}",
|
||||
name, exitCode?.ToString() ?? "unknown", Environment.NewLine, output);
|
||||
}
|
||||
|
||||
// Prepended to a strict-mode body. Written on ONE line so every line number
|
||||
// PowerShell reports still matches the body the author sees in the WebUI
|
||||
// editor: a multi-line prologue would shift them and make the error text
|
||||
// point at the wrong statement during exactly the debugging session that
|
||||
// needed it.
|
||||
private const string StrictModePrologue =
|
||||
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'";
|
||||
|
||||
// Turn a script's non-terminating errors fatal when the library says strict.
|
||||
//
|
||||
// MUST run AFTER the SHA-256 check. The digest pins the bytes the SERVER
|
||||
// holds; prepending first would rewrite the file and fail its own integrity
|
||||
// check. Verify what was delivered, then modify how it is interpreted.
|
||||
//
|
||||
// Best-effort by design: if the rewrite fails the script still runs under
|
||||
// PowerShell's default rules. That is the behaviour it had before this
|
||||
// existed, so a failed rewrite degrades to "as before" rather than blocking
|
||||
// work, and the warning says the verdict is the weaker one.
|
||||
private void ApplyErrorMode(ScriptDeliveryClaim script, string scriptPath, string name)
|
||||
{
|
||||
if (!string.Equals(script.ErrorMode, "strict", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var body = File.ReadAllText(scriptPath);
|
||||
File.WriteAllText(scriptPath, StrictModePrologue + Environment.NewLine + body);
|
||||
logger.LogInformation(
|
||||
"run_script {Name}: strict error mode applied; a non-terminating "
|
||||
+ "error will now stop the script and fail the command", name);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(
|
||||
error,
|
||||
"run_script {Name}: could not apply strict error mode; running under "
|
||||
+ "PowerShell defaults, so a non-terminating error will NOT fail the command",
|
||||
name);
|
||||
}
|
||||
}
|
||||
|
||||
// Where the script actually runs. Empty means the agent staging directory,
|
||||
// which is right for a self-contained script and wrong for one that shells
|
||||
// out to a corporate tool already installed on the endpoint (Sysinternals
|
||||
// and similar, delivered by Intune) and invokes it by relative path. The
|
||||
// server carries that folder per script.
|
||||
//
|
||||
// A NAMED DIRECTORY THAT IS ABSENT FAILS THE COMMAND. Falling back to
|
||||
// staging would run SYSTEM PowerShell somewhere other than where the author
|
||||
// said; the script would then fail further in with an error naming the
|
||||
// missing tool rather than the missing folder, or half-succeed, which is
|
||||
// worse. Fail closed and name the folder.
|
||||
private static bool TryResolveWorkingDirectory(
|
||||
ScriptDeliveryClaim? script, string scriptPath,
|
||||
out string workingDirectory, out string error)
|
||||
{
|
||||
error = "";
|
||||
workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
|
||||
var requested = script?.WorkingDirectory;
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
return true;
|
||||
|
||||
if (!Directory.Exists(requested))
|
||||
{
|
||||
error = $"working directory not found on this endpoint: {requested}";
|
||||
return false;
|
||||
}
|
||||
|
||||
workingDirectory = requested!;
|
||||
return true;
|
||||
}
|
||||
|
||||
// System context: run through the agent's own service account and wait for
|
||||
// the exit code, the same as before. Fine for machine-scoped scripts that do
|
||||
// not need the interactive user's session resources.
|
||||
private async Task<CommandResult> RunScriptAsSystem(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath, CancellationToken token)
|
||||
{
|
||||
if (!TryResolveWorkingDirectory(
|
||||
script, scriptPath, out var workingDirectory, out var directoryError))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} not started: {Error}", name, directoryError);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} not run: {directoryError}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? "",
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
@@ -960,19 +1146,27 @@ public sealed class CommandExecutor
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start script {name}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory),
|
||||
};
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(token);
|
||||
// Read only AFTER exit, for the reason spelled out on the schtasks path:
|
||||
// ReadToEnd blocks until the streams close, which IS process exit, so
|
||||
// reading first would make the caller's cancellation unable to fire.
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(token);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(token);
|
||||
var exit = process.ExitCode;
|
||||
var output = CombineScriptOutput(stdout, stderr);
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = exit == 0,
|
||||
Message = exit == 0
|
||||
? $"Script {name} completed (system context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
exit == 0 ? null : output),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,13 +1182,57 @@ public sealed class CommandExecutor
|
||||
// hung script pinned the caller forever.
|
||||
private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// Where a user-session script's output is captured to.
|
||||
//
|
||||
// The SYSTEM path can simply redirect the child's stdout/stderr handles.
|
||||
// This path cannot: it goes through CreateProcessAsUser to cross a session
|
||||
// boundary, and inheriting pipe handles across that boundary is exactly the
|
||||
// part of the launcher that is delicate (session enumeration, token
|
||||
// duplication). So PowerShell redirects to a file itself and the agent reads
|
||||
// it back after exit.
|
||||
//
|
||||
// The file lands in the OPERATOR's profile, not the agent's ProgramData
|
||||
// staging folder: staging is SYSTEM-owned and the signed-in operator cannot
|
||||
// write there, which is the same constraint that put the tray's own log in
|
||||
// the profile. Safe to rely on here because a user-context script only runs
|
||||
// when someone is interactively signed in, and a signed-in account has a
|
||||
// materialised profile by definition.
|
||||
private static string UserTranscriptPath(int scriptId)
|
||||
=> Path.Combine(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
$"shopdb-script-{scriptId}-output.txt");
|
||||
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
{
|
||||
var quotedPath = "\"" + scriptPath + "\"";
|
||||
// Redirect inside the user session rather than from the service. The
|
||||
// child is launched through CreateProcessAsUser, so the agent holds no
|
||||
// handles to its streams; -Command wraps the same -File invocation and
|
||||
// sends both streams to a file the operator can write, which the agent
|
||||
// reads back after exit. 2>&1 merges stderr into the same transcript
|
||||
// because ordering between the two matters more than telling them apart
|
||||
// when reconstructing what a script did.
|
||||
var transcriptPath = UserTranscriptPath(script.ScriptId);
|
||||
var quotedTranscript = "\"" + transcriptPath + "\"";
|
||||
TryEnsureTranscriptDirectory(transcriptPath, name);
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + quotedPath;
|
||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "
|
||||
+ "\"& { & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy "
|
||||
+ "Bypass -File " + quotedPath.Replace("\"", "\\\"")
|
||||
+ " *> " + quotedTranscript.Replace("\"", "\\\"") + "; exit $LASTEXITCODE }\"";
|
||||
if (!TryResolveWorkingDirectory(
|
||||
script, scriptPath, out var workingDirectory, out var directoryError))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} not started: {Error}", name, directoryError);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} not run: {directoryError}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true,
|
||||
@@ -1008,7 +1246,8 @@ public sealed class CommandExecutor
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} could not run in the user session: {outcome.Error}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory,
|
||||
ReadAndClearTranscript(transcriptPath, name)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1018,19 +1257,22 @@ public sealed class CommandExecutor
|
||||
// as unresolved rather than success, because we never saw an exit
|
||||
// code and cannot claim it worked.
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} still running after {Timeout}; stopped waiting",
|
||||
name, UserScriptWaitTimeout);
|
||||
"run_script for {Name} still running after {Timeout}; stopped waiting. "
|
||||
+ "Partial output remains at {Transcript} while it runs",
|
||||
name, UserScriptWaitTimeout, transcriptPath);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} did not finish within "
|
||||
+ $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null, workingDirectory),
|
||||
};
|
||||
}
|
||||
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
var output = ReadAndClearTranscript(transcriptPath, name);
|
||||
LogScriptOutput(name, output, exit);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
@@ -1039,12 +1281,14 @@ public sealed class CommandExecutor
|
||||
: ok
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit),
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
ok ? null : output),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ScriptDetail(
|
||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode)
|
||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode,
|
||||
string? workingDirectory = null, string? output = null)
|
||||
{
|
||||
var detail = new Dictionary<string, object>
|
||||
{
|
||||
@@ -1056,11 +1300,27 @@ public sealed class CommandExecutor
|
||||
detail["scriptid"] = script.ScriptId;
|
||||
if (!string.IsNullOrWhiteSpace(script.RunContext))
|
||||
detail["runcontext"] = script.RunContext!;
|
||||
if (!string.IsNullOrWhiteSpace(script.ErrorMode))
|
||||
detail["errormode"] = script.ErrorMode!;
|
||||
}
|
||||
if (scriptPath is not null)
|
||||
detail["path"] = scriptPath;
|
||||
// The directory the script REALLY ran in, not the one that was asked
|
||||
// for. An agent too old to know the field reports nothing here, which is
|
||||
// how an operator tells "ignored" apart from "honoured" while agents of
|
||||
// both vintages are in the field.
|
||||
if (!string.IsNullOrWhiteSpace(workingDirectory))
|
||||
detail["workingdirectory"] = workingDirectory!;
|
||||
if (exitCode is not null)
|
||||
detail["exitcode"] = exitCode;
|
||||
// FAILURE ONLY. Callers pass output only when the command failed, so a
|
||||
// successful run's stdout never leaves the endpoint. Script output can
|
||||
// carry whatever the body happened to print - hostnames, paths, tokens
|
||||
// an author echoed while debugging - and there is no reason to accept
|
||||
// that exposure for a run nobody needs to diagnose. The endpoint log
|
||||
// keeps the full copy either way.
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
detail["output"] = output!;
|
||||
return detail;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,21 @@ public sealed class AppDeliveryClaim
|
||||
// downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// RunContext is 'system' (the agent's own service account) or 'user' (the
|
||||
// interactive logged-on session), decided per script in the ShopDB library.
|
||||
// WorkingDirectory is the optional folder to run the script IN. Empty means the
|
||||
// agent's staging directory, which is what every script did before the field
|
||||
// existed. It is there so a script can sit beside a corporate tool already
|
||||
// installed on the endpoint and invoke it by relative path; it confers no
|
||||
// rights, since the body is already arbitrary PowerShell that could change its
|
||||
// own directory.
|
||||
// ErrorMode is 'default' or 'strict'. Default is what PowerShell does unaided:
|
||||
// a non-terminating error is written and the script continues, so powershell.exe
|
||||
// still exits 0 and the command reports SUCCEEDED even when the body did none of
|
||||
// its work. Strict makes the agent prepend $ErrorActionPreference = 'Stop' so the
|
||||
// first error terminates and the exit code means what an operator reads it to
|
||||
// mean. Per script, never global: strict changes execution semantics, and a body
|
||||
// written to tolerate a failing cleanup or to probe with a cmdlet that errors on
|
||||
// absence would start aborting halfway. An absent or unrecognised value is
|
||||
// treated as default, so an old server that never sends the field is safe.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
@@ -189,6 +204,8 @@ public sealed class ScriptDeliveryClaim
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
||||
[JsonPropertyName("errormode")] public string? ErrorMode { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -131,6 +131,21 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<!-- 1.13.0: run_script honors a per-script working directory. The script
|
||||
library gains an optional workingdirectory, the server carries it in the
|
||||
run_script descriptor, and ScriptDeliveryClaim gains WorkingDirectory
|
||||
(AGENT-CONTRACT.md). The point is corporate tooling shipped by Intune: a
|
||||
script that shells out to a vendor CLI needs to run beside that CLI, not
|
||||
in the staging folder. Empty means the existing staging path, so older
|
||||
packages and every existing script keep their current behavior - this is
|
||||
additive, hence minor. Fails closed: a named directory that does not
|
||||
exist on the endpoint aborts the run with a clear error rather than
|
||||
silently falling back to staging, because a script that ran in the wrong
|
||||
folder is worse than one that did not run. The directory confers no
|
||||
rights of its own - it is a cwd, not a grant, and the run still happens
|
||||
in the script's configured context. Server-side validation rejects UNC
|
||||
paths and traversal before the value is ever persisted. No tray change -
|
||||
tray stays 1.4.0. -->
|
||||
<!-- 1.12.0: startup jitter before first contact. Both loops previously made
|
||||
their first request the instant the service started, and the service is
|
||||
installed start=auto, so a patch reboot put the whole fleet on the wire
|
||||
@@ -181,7 +196,25 @@
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.5: coordinated release with tray 1.6.6 (no tray changes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<Version>1.13.5</Version>
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. No tray change - tray
|
||||
stays 1.4.0. -->
|
||||
<!-- 1.14.1: coordinated release with tray 1.6.6 (fleet alert fixes).
|
||||
Merge wilm 1.14.0 changes with wj tray coordination releases. -->
|
||||
<Version>1.14.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -827,8 +827,11 @@ export const applicationsApi = {
|
||||
}
|
||||
|
||||
// ShopDB-hosted script library. Backs run_script delivery: the agent fetches a
|
||||
// script body by digest with its collector credential and runs it in the
|
||||
// script's configured run context (system or user).
|
||||
// script body by digest with its collector credential and runs it as SYSTEM.
|
||||
// The UI does not offer a run context - scripts exist to trigger an already
|
||||
// registered scheduled task, and the agent service has no user session to
|
||||
// borrow. Anything needing a user's own token must be that scheduled task's
|
||||
// problem, not this library's.
|
||||
export const scriptsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/scripts', { params })
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// (landing) read the merged catalog so plugins add settings cards with no core
|
||||
// edit. Fetched once into a module-level ref and shared across callers.
|
||||
import { ref, computed } from 'vue'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound } from 'lucide-vue-next'
|
||||
import api from '../api'
|
||||
import router from '../router'
|
||||
import { settingsGroups } from '../views/settings/settingsNav'
|
||||
@@ -24,6 +24,10 @@ const iconMap = {
|
||||
network: Network,
|
||||
printer: Printer,
|
||||
droplets: Droplets,
|
||||
terminal: Terminal,
|
||||
// The endpoints agent-key card has asked for 'key' since it shipped and has
|
||||
// been silently drawing a puzzle piece the whole time.
|
||||
key: KeyRound,
|
||||
}
|
||||
|
||||
function resolveIcon(key) {
|
||||
|
||||
41
migrations/versions/7d48_script_workingdirectory.py
Normal file
41
migrations/versions/7d48_script_workingdirectory.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Add scripts workingdirectory
|
||||
|
||||
Backs the optional "run in this folder" field on a library script. The agent
|
||||
otherwise runs a delivered script from its own staging directory, which is
|
||||
correct for a self-contained script and wrong for one that shells out to a
|
||||
corporate tool already installed on the endpoint (Sysinternals and similar,
|
||||
delivered by Intune) and referenced by relative path.
|
||||
|
||||
NULLABLE with no default, so every script row that existed before this
|
||||
migration stays valid and keeps today's behaviour exactly: empty means the
|
||||
staging directory. The path is validated in shopdb/core/api/scripts.py where a
|
||||
rejection can explain itself, not in the schema where it would only produce an
|
||||
IntegrityError.
|
||||
|
||||
This column grants no capability. A script body is already arbitrary PowerShell
|
||||
run as SYSTEM and can change its own directory; the boundary is the
|
||||
endpoints.runscript grant and the digest-pinned library.
|
||||
|
||||
Revision ID: 7d48_script_workingdir
|
||||
Revises: 7d47_endpoints_operatorreports
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d48_script_workingdir'
|
||||
down_revision = '7d47_endpoints_operatorreports'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('scripts', sa.Column(
|
||||
'workingdirectory', sa.String(length=255), nullable=True,
|
||||
comment='Folder to run the script in; empty means the agent staging dir'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('scripts', 'workingdirectory')
|
||||
46
migrations/versions/7d49_script_errormode.py
Normal file
46
migrations/versions/7d49_script_errormode.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Add scripts errormode
|
||||
|
||||
Backs the per-script "how should a non-terminating error be treated" field.
|
||||
|
||||
PowerShell invoked with -File exits 0 whenever it reaches the end of the body,
|
||||
even if every meaningful line inside wrote an error. The agent derives the
|
||||
command verdict from that exit code, so a script could report SUCCEEDED while
|
||||
doing none of its work - which is exactly how an account-provisioning script
|
||||
came back green with the account only partly built.
|
||||
|
||||
NOT NULL with server_default 'default', so every script row that existed before
|
||||
this migration keeps today's permissive behaviour exactly. Strict is opt-in per
|
||||
script and never retroactive: turning it on globally would change the meaning of
|
||||
bodies that were authored under the permissive rule and deliberately tolerate a
|
||||
failing Remove-Item, probe with Get-LocalUser, or loop past a bad item. Those
|
||||
would begin aborting partway through, which is worse than a false green because
|
||||
it leaves the endpoint half-configured.
|
||||
|
||||
The value is validated in shopdb/core/api/scripts.py where a rejection can
|
||||
explain itself, not in the schema where it would only produce an IntegrityError.
|
||||
|
||||
Revision ID: 7d49_script_errormode
|
||||
Revises: 7d48_script_workingdir
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d49_script_errormode'
|
||||
down_revision = '7d48_script_workingdir'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('scripts', sa.Column(
|
||||
'errormode', sa.String(length=16), nullable=False,
|
||||
server_default='default',
|
||||
comment="'default' keeps PowerShell's permissive behaviour; "
|
||||
"'strict' stops on the first error so the exit code is honest"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('scripts', 'errormode')
|
||||
@@ -19,7 +19,7 @@ from shopdb.api import (db, AuditLog, success_response, error_response,
|
||||
from .routes import computers_bp
|
||||
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
|
||||
POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED,
|
||||
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
|
||||
from ..policyflag import policy_flag_enabled
|
||||
from ..services.apppolicy import disown_if_generated
|
||||
@@ -262,8 +262,12 @@ def update_configuration_policy(policy_id: int):
|
||||
'Configuration policy not found', http_code=404)
|
||||
# An autoapp row is generated from an Application's "Required on all PCs"
|
||||
# checkbox and IS editable: editing it is precisely how an admin takes
|
||||
# ownership. Genuinely external sources (shipped/managed) stay read-only.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
|
||||
# ownership. A shipped row is editable for the same reason - the product
|
||||
# ships a starting point, not an opinion about one site's floor - and keeps
|
||||
# source='shipped' so provenance survives the edit. Genuinely external
|
||||
# sources (a future managed owner) stay read-only.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_SHIPPED):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
@@ -326,9 +330,11 @@ def delete_configuration_policy(policy_id: int):
|
||||
if not policy:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'Configuration policy not found', http_code=404)
|
||||
# As with update: a generated row is the site's to remove, an externally
|
||||
# managed one is not.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP):
|
||||
# As with update: a generated or shipped row is the site's to remove, an
|
||||
# externally managed one is not. A site that does not run MTConnect should
|
||||
# be able to delete its monitor policy outright rather than carry it.
|
||||
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
|
||||
POLICY_SOURCE_SHIPPED):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'Policy is managed externally and is read-only',
|
||||
http_code=409)
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="!iseditable(p)" @click="openModal(p)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
@@ -287,6 +287,18 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the API guard in plugins/computers/api/policy_routes.py: local,
|
||||
// autoapp and shipped rows are all editable. A shipped row is a starting point
|
||||
// the product supplies, not a fixed opinion, and it keeps source='shipped' so
|
||||
// provenance survives the edit. Only a genuinely external managed owner is
|
||||
// read-only. Disabling on source==='local' alone stranded shipped rows behind
|
||||
// dead buttons while the API was accepting the write.
|
||||
const EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']
|
||||
|
||||
function iseditable(p) {
|
||||
return EDITABLE_SOURCES.includes(p.source)
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
|
||||
try {
|
||||
|
||||
@@ -512,6 +512,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run Scripts: pushes a ShopDB-hosted script from the library. Distinct
|
||||
from Corporate Tools above - that starts a task the endpoint already
|
||||
approved, this delivers script content ShopDB holds. Separate grant
|
||||
(endpoints.runscript), separate tab, so neither implies the other. -->
|
||||
<div v-show="activegroup === 'runscripts'" class="detail-full">
|
||||
<div class="card">
|
||||
<h3>Run Scripts</h3>
|
||||
<p class="corptools-intro">
|
||||
Sends a script from the ShopDB script library to this computer. The
|
||||
agent fetches the body by digest and runs it in the context the
|
||||
script itself specifies.
|
||||
</p>
|
||||
<!-- Persistent, same reasoning as the Corporate Tools warning: this
|
||||
is arbitrary code on a live machine, and the operator must still
|
||||
be able to see that when they decide to press Run. -->
|
||||
<p class="corptools-warning">
|
||||
A script here runs as SYSTEM unless it is configured to run as the
|
||||
logged-on user. It takes effect immediately on the live computer and
|
||||
cannot be undone from here.
|
||||
</p>
|
||||
|
||||
<div v-if="scriptsloading" class="runscripts-empty">Loading scripts...</div>
|
||||
<div v-else-if="runnablescripts.length === 0" class="runscripts-empty">
|
||||
No runnable scripts. A script needs an uploaded body before it can be
|
||||
sent; add one under Settings then return here.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label for="runscript-picker">Script</label>
|
||||
<select
|
||||
id="runscript-picker"
|
||||
v-model="selectedscriptid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select a script...</option>
|
||||
<option
|
||||
v-for="script in runnablescripts"
|
||||
:key="script.scriptid"
|
||||
:value="script.scriptid"
|
||||
>{{ script.scriptname }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- What the operator is about to run, stated before they run it.
|
||||
Run context and working directory are authoring decisions made
|
||||
in the library and are shown read-only here: overriding either
|
||||
at push time would mean the script that ran is not the script
|
||||
that was reviewed. -->
|
||||
<div v-if="selectedscript" class="runscripts-detail">
|
||||
<p v-if="selectedscript.scriptdescription" class="runscripts-desc">
|
||||
{{ selectedscript.scriptdescription }}
|
||||
</p>
|
||||
<dl class="runscripts-facts">
|
||||
<dt>Runs as</dt>
|
||||
<dd>
|
||||
{{ selectedscript.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
<dt>Runs in</dt>
|
||||
<dd class="mono">
|
||||
{{ selectedscript.workingdirectory || 'Agent staging folder' }}
|
||||
</dd>
|
||||
</dl>
|
||||
<p
|
||||
v-if="selectedscript.workingdirectory"
|
||||
class="runscripts-note"
|
||||
>
|
||||
This script requires that folder to exist on the computer. If it
|
||||
does not, the run fails and reports the missing folder rather
|
||||
than running somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="runscripts-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!selectedscript || firingScript"
|
||||
@click="confirmRunScript"
|
||||
>{{ firingScript ? 'Sending...' : 'Run Script' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
@@ -686,7 +770,7 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi, scriptsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -829,6 +913,16 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||
// during setup, which reads canDeploy before this line would otherwise run.
|
||||
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
|
||||
|
||||
// Gates the Run Scripts tab. Deliberately NOT canDeploy: endpoints.corptools
|
||||
// starts a task the endpoint already holds and has allowlisted, while
|
||||
// endpoints.runscript delivers arbitrary PowerShell that runs as SYSTEM unless
|
||||
// the script pins a user context. Those were split out of the old
|
||||
// endpoints.deploy bundle for exactly this reason (see
|
||||
// ELEVATED_ACTION_PERMISSIONS in plugins/endpoints/commands.py), so hanging
|
||||
// scripts off the Corporate Tools gate would put the bundle back together in
|
||||
// the UI after the backend took it apart.
|
||||
const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript'))
|
||||
|
||||
// Operator report screenshots are an uncontrolled capture of whatever was on
|
||||
// the operator's screen, so they carry their own grant rather than riding
|
||||
// endpoints.view. Declared alongside canDeploy for the same reason: detailtabs
|
||||
@@ -868,6 +962,15 @@ const detailtabs = computed(() => {
|
||||
if (canDeploy.value && liveness.value) {
|
||||
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
|
||||
}
|
||||
// Run Scripts is its own tab rather than a card in Corporate Tools because it
|
||||
// is a different grant and a different trust model - a library of arbitrary
|
||||
// PowerShell delivered by ShopDB, versus a closed set of tasks the endpoint
|
||||
// already approved. Liveness-gated for the same reason as Corporate Tools:
|
||||
// the command queues against a live agent, so offering it for a machine that
|
||||
// is not checking in only produces a command nothing will collect.
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1377,6 +1480,47 @@ const corporateTools = [
|
||||
|
||||
const firingTask = ref('')
|
||||
|
||||
// Run Scripts state. The library is fetched rather than hardcoded (the opposite
|
||||
// of corporateTools above) because these are site-authored scripts that change
|
||||
// without an agent release.
|
||||
const scriptlibrary = ref([])
|
||||
const scriptsloading = ref(false)
|
||||
const scriptsloaded = ref(false)
|
||||
const selectedscriptid = ref('')
|
||||
const firingScript = ref(false)
|
||||
|
||||
// Only scripts that can actually run. A row with no uploaded body is rejected
|
||||
// at enqueue by _resolve_run_script, so listing it would offer a button whose
|
||||
// only outcome is an error; the library page is where that gets fixed.
|
||||
const runnablescripts = computed(
|
||||
() => scriptlibrary.value.filter((script) => script.hasbody))
|
||||
|
||||
const selectedscript = computed(
|
||||
() => runnablescripts.value.find(
|
||||
(script) => String(script.scriptid) === String(selectedscriptid.value)) || null)
|
||||
|
||||
// Lazy and permission-gated: an operator without endpoints.runscript never has
|
||||
// the tab, and so never issues a request the server would refuse anyway.
|
||||
async function loadScriptLibrary() {
|
||||
if (scriptsloaded.value || scriptsloading.value) return
|
||||
scriptsloading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'true' })
|
||||
scriptlibrary.value = response.data.data || []
|
||||
scriptsloaded.value = true
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to load the script library'))
|
||||
} finally {
|
||||
scriptsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
})
|
||||
|
||||
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||
// confirmRestart - these start real work on a live machine and some of them
|
||||
// change system state - and the server re-checks endpoints.corptools regardless.
|
||||
@@ -1412,6 +1556,41 @@ async function confirmRunTask(tool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated: queues a run_script. commands.py asks for this to be gated "the way
|
||||
// system_restart is gated", so the confirmation is TYPED rather than an OK
|
||||
// button - and unlike Corporate Tools there is no non-destructive branch,
|
||||
// because every script here is arbitrary code by definition. The confirm names
|
||||
// the context and the folder so the operator sees what the library decided, not
|
||||
// just the script's name.
|
||||
async function confirmRunScript() {
|
||||
const script = selectedscript.value
|
||||
if (!script) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
const context = script.runcontext === 'user'
|
||||
? 'the logged-on user'
|
||||
: 'SYSTEM'
|
||||
const folder = script.workingdirectory || 'the agent staging folder'
|
||||
const typed = prompt(`Run "${script.scriptname}" on ${label}\n\n`
|
||||
+ `It runs as ${context}, in ${folder}.\n`
|
||||
+ 'It starts immediately and cannot be undone from here.'
|
||||
+ '\n\nType RUN to confirm.')
|
||||
if (typed !== 'RUN') return
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
firingScript.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
@@ -1816,7 +1995,7 @@ async function confirmRetire() {
|
||||
it does before the confirm gate rather than after. */
|
||||
.corptools-intro {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* Warning weight without alert-banner styling: it is permanent, so a loud
|
||||
@@ -1825,9 +2004,9 @@ async function confirmRetire() {
|
||||
.corptools-warning {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 3px solid var(--warning, #d97706);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.corptools-list {
|
||||
@@ -1848,7 +2027,7 @@ async function confirmRetire() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.corptools-desc {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
@@ -1858,12 +2037,61 @@ async function confirmRetire() {
|
||||
.corptools-rowwarning {
|
||||
margin: 0.25rem 0 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-left: 3px solid var(--danger, #dc2626);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-left: 3px solid var(--danger);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Run Scripts reuses the Corporate Tools intro/warning styles above; only the
|
||||
picker-specific pieces are new. */
|
||||
.runscripts-empty {
|
||||
padding: 0.75rem 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.runscripts-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.runscripts-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
/* Two columns so the label and its value stay on one line: the folder is the
|
||||
fact most likely to be skimmed, and wrapping it under its own label made it
|
||||
read as body copy in review. */
|
||||
.runscripts-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.runscripts-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
/* Defined locally: .mono is not a global utility in this build, and a path
|
||||
rendered in the body font is harder to read back against the endpoint. */
|
||||
.runscripts-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
.runscripts-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const view = readFileSync(join(here, 'ConfigurationPoliciesList.vue'), 'utf8')
|
||||
|
||||
// The API decides what may be edited; the buttons only reflect that decision.
|
||||
// These drifted once: policy_routes.py was widened to accept shipped rows while
|
||||
// the buttons still disabled on source !== 'local', so every shipped policy sat
|
||||
// behind a dead Edit button and the change looked like it had never rolled.
|
||||
// If the API guard changes, EDITABLE_SOURCES here changes with it.
|
||||
describe('the configuration policy buttons follow the API guard', () => {
|
||||
it('treats shipped as editable, not read-only', () => {
|
||||
expect(view).toContain("EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']")
|
||||
})
|
||||
|
||||
it('does not gate the buttons on local alone', () => {
|
||||
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
|
||||
})
|
||||
|
||||
it('gates edit and delete on the same rule', () => {
|
||||
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
|
||||
expect(disabled).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Run Scripts tab on PC Details.
|
||||
*
|
||||
* The thing worth pinning here is the SEPARATION. run_script and
|
||||
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
|
||||
* (endpoints.runscript and endpoints.corptools), split out of a single
|
||||
* endpoints.deploy bundle precisely so that being able to trigger a corporate
|
||||
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
|
||||
*
|
||||
* That split lives in the backend. The UI can quietly undo it by hanging the
|
||||
* script picker off the Corporate Tools tab, whose only gate is
|
||||
* endpoints.corptools - and the failure would be invisible in review, because
|
||||
* the page would look correct and the server would simply 403 the push. These
|
||||
* tests fail if someone merges the two.
|
||||
*
|
||||
* Asserted against the file text, matching systemScriptsSettings.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
|
||||
|
||||
describe('PCDetail Run Scripts tab', () => {
|
||||
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
|
||||
expect(source).toMatch(
|
||||
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
|
||||
})
|
||||
|
||||
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
|
||||
// The corptools block must not gain a run_script call: that would put the
|
||||
// action behind endpoints.corptools in the UI while the server still
|
||||
// demands endpoints.runscript.
|
||||
const corptools = source.slice(
|
||||
source.indexOf("activegroup === 'corptools'"),
|
||||
source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(corptools).not.toMatch(/run_script/)
|
||||
expect(source).toMatch(/activegroup === 'runscripts'/)
|
||||
})
|
||||
|
||||
it('enqueues run_script with a script-id target', () => {
|
||||
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
|
||||
})
|
||||
|
||||
it('requires a typed confirmation before running', () => {
|
||||
// Every script on this tab is arbitrary code, so unlike Corporate Tools
|
||||
// there is no safe branch that gets a plain OK button.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/Type RUN to confirm/)
|
||||
expect(body).toMatch(/typed !== 'RUN'/)
|
||||
})
|
||||
|
||||
it('warns that a script runs as SYSTEM before it is sent', () => {
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
|
||||
})
|
||||
|
||||
it('shows the run context and working directory read-only', () => {
|
||||
// Both are authoring decisions made in the library. Offering an override
|
||||
// here would mean the script that ran is not the script that was reviewed,
|
||||
// so they must be rendered as facts and never bound with v-model.
|
||||
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
|
||||
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
|
||||
expect(block).toMatch(/selectedscript\.workingdirectory/)
|
||||
expect(block).toMatch(/selectedscript\.runcontext/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
|
||||
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
|
||||
})
|
||||
|
||||
it('offers only scripts that have a body', () => {
|
||||
// A bodyless script is rejected at enqueue by _resolve_run_script, so
|
||||
// listing it would offer a button whose only outcome is an error.
|
||||
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
|
||||
})
|
||||
|
||||
it('reports the push as queued rather than as finished', () => {
|
||||
// The agent collects the command on its next check-in; claiming it ran
|
||||
// would assert an outcome nothing has observed.
|
||||
const handler = source.slice(source.indexOf('async function confirmRunScript'))
|
||||
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
|
||||
})
|
||||
|
||||
it('styles the Run Scripts tab from defined theme tokens only', () => {
|
||||
// --text-primary/--text-secondary/--bg-secondary are not defined by this
|
||||
// app's theme. var() then falls through to the fallback, or to inherit,
|
||||
// which reads fine in light mode and misrenders for dark-mode users - the
|
||||
// failure only shows up on the theme most of the shop floor runs.
|
||||
const runscripts = source.slice(source.indexOf('.corptools-intro'))
|
||||
expect(runscripts).not.toMatch(/--text-primary|--text-secondary|--bg-secondary|--surface/)
|
||||
expect(runscripts).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
})
|
||||
@@ -27,10 +27,21 @@ from .jsontext import JSONText
|
||||
# owner. Kept a plain string, not an enum column, so adding a source is data.
|
||||
POLICY_SOURCE_LOCAL = 'local'
|
||||
|
||||
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
|
||||
# treatment as any non-local source: the CRUD API refuses to edit or delete
|
||||
# them, the seeder reconciles them. Distinct from 'local' so a site's own
|
||||
# policies are never confused with what the product shipped.
|
||||
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
|
||||
# Distinct from 'local' so a site's own policies are never confused with what
|
||||
# the product shipped, and so the UI can badge the origin.
|
||||
#
|
||||
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
|
||||
# The product ships a starting point, not an opinion about one site's floor: a
|
||||
# plant that runs no MTConnect must be able to drop that policy. source stays
|
||||
# 'shipped' through an edit, so provenance is a fact about where the row came
|
||||
# from rather than a claim about who controls it now.
|
||||
#
|
||||
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
|
||||
# (they SELECT by name and return early if it exists), so an upgrade cannot
|
||||
# stomp a site's edits. That property is load-bearing now that the rows are
|
||||
# editable - do not replace it with a reconciling seeder without adding a
|
||||
# dirty flag first.
|
||||
POLICY_SOURCE_SHIPPED = 'shipped'
|
||||
|
||||
# Rows generated FOR US by another surface rather than authored by hand: today
|
||||
|
||||
@@ -465,6 +465,9 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
connections with a `truncated` flag in `detail`, because an oversized payload
|
||||
rejected by the server returns nothing at all and a partial answer is more
|
||||
useful than a failed one. Standard reports carry listening ports only.
|
||||
- `run_script` - run a reviewed PowerShell script from the script library. The
|
||||
body is NOT carried on the command channel; the claim carries a descriptor and
|
||||
the agent fetches the bytes separately (section 7.4).
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
@@ -509,3 +512,69 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
gated by user JWT + `endpoints.command`. Only one command may be in flight per
|
||||
endpoint (a second enqueue returns HTTP 409 until the first finalizes).
|
||||
|
||||
### 7.4 `run_script` claim descriptor
|
||||
|
||||
A `run_script` claim carries a `script` object alongside the usual
|
||||
`endpointcommandid` and `action`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scriptid": 12,
|
||||
"scriptname": "sfld_autologon",
|
||||
"installpath": "/api/endpoints/scriptbody/12", // fetch with X-API-Key
|
||||
"sha256": "3f9a...", // 64 hex chars, lowercase
|
||||
"runcontext": "system", // "system" | "user"
|
||||
"workingdirectory": "C:\\Tools", // "" = agent staging folder
|
||||
"errormode": "default" // "default" | "strict"
|
||||
}
|
||||
```
|
||||
|
||||
The agent MUST download `installpath`, verify the SHA-256 of the received bytes
|
||||
against `sha256`, and refuse to run on mismatch. The body never travels on the
|
||||
command channel so a queued command cannot be used to smuggle code past review.
|
||||
|
||||
`workingdirectory` is the folder the script runs from. Empty means the agent's
|
||||
staging directory, which is correct for a self-contained script and wrong for
|
||||
one invoking a separately-installed tool by relative path. A pinned folder that
|
||||
does not exist MUST fail the command rather than falling back, so the script
|
||||
never runs somewhere unintended.
|
||||
|
||||
`errormode` selects how the run is judged:
|
||||
|
||||
- `default` - PowerShell's normal behaviour. Most errors are non-terminating:
|
||||
the script writes the error, continues, and still exits zero.
|
||||
- `strict` - the agent prepends `$ErrorActionPreference = 'Stop'` so the first
|
||||
error terminates the run and the exit code reflects reality.
|
||||
|
||||
`default` is the default precisely BECAUSE it is permissive. Every script
|
||||
authored before this field existed relies on that behaviour, and some rely on it
|
||||
deliberately - tolerating a failing cleanup, probing with a cmdlet that errors
|
||||
when a thing is absent, looping past a bad item. Promoting `strict` globally
|
||||
would make those bodies abort partway and leave endpoints half-configured, so it
|
||||
is opt-in per script.
|
||||
|
||||
Two consequences the agent MUST honour:
|
||||
|
||||
- The strict prologue is applied AFTER digest verification, so the hash still
|
||||
validates the bytes the server actually delivered.
|
||||
- Because the prologue adds a line, an error reported on line N of a strict run
|
||||
refers to line N-1 of the stored body. Surfaces showing the body should say so.
|
||||
|
||||
An agent that does not recognise an `errormode` value MUST treat it as
|
||||
`default`. The server always sends the field explicitly, even at the default, so
|
||||
an agent that received it and ignored it stays distinguishable from one that
|
||||
never saw it.
|
||||
|
||||
#### Script output
|
||||
|
||||
Script stdout/stderr is ALWAYS written to the local agent log on the endpoint.
|
||||
It is returned to the server in `detail.output` only when the command FAILED.
|
||||
Routine output stays on the machine that produced it: shipping every successful
|
||||
run back would put arbitrary script output, including whatever an author printed
|
||||
while debugging, into the server's command history for no diagnostic gain.
|
||||
Output is truncated to 8000 characters and flagged when truncated, because an
|
||||
oversized payload rejected by the server returns nothing at all.
|
||||
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
|
||||
@@ -2072,9 +2072,15 @@ def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
Accepts either "script:<scriptid>" or a bare script name. The descriptor
|
||||
carries a collector-authenticated download URL, the body's SHA-256, and the
|
||||
per-script run context (system or user). The agent fetches the body with
|
||||
its collector credential and runs it; the body never crosses the wire.
|
||||
carries a collector-authenticated download URL, the body's SHA-256, the
|
||||
per-script run context (system or user), and the optional folder to run in.
|
||||
The agent fetches the body with its credential and runs it; the body never
|
||||
crosses the wire.
|
||||
|
||||
workingdirectory is sent even when empty so the field is always present in
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2102,6 +2108,8 @@ def _resolve_run_script(target):
|
||||
'installpath': '/api/endpoints/scriptbody/{}'.format(script.scriptid),
|
||||
'sha256': script.sha256,
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
}, None
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ export default [
|
||||
name: 'endpoints-monitors-settings',
|
||||
component: () => import('./views/MonitorsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
|
||||
},
|
||||
// requiresPermission, not requiresAdmin: endpoints.runscript is grantable to
|
||||
// non-admin operators, and the sibling pages' admin gate would lock the
|
||||
// holders of the grant out of the library they are trusted to manage. The
|
||||
// API enforces the same permission, so this only keeps the menu honest.
|
||||
{
|
||||
path: 'settings/endpoints/scripts',
|
||||
name: 'endpoints-scripts-settings',
|
||||
component: () => import('./views/SystemScriptsSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.runscript',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
412
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>System Scripts</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Script</button>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
PowerShell the site agent can be told to run on an endpoint. A script is
|
||||
stored here once, pinned by digest, and fetched by the agent at push time
|
||||
so the bytes that run are the bytes that were reviewed.
|
||||
</p>
|
||||
|
||||
<div class="warning-banner">
|
||||
<strong>Every script here runs as SYSTEM.</strong>
|
||||
There is no user context and no prompt: the agent is a service, so a
|
||||
script cannot see a signed-in person's desktop, mapped drives, or
|
||||
credentials. Scripts are intended to TRIGGER an already registered
|
||||
scheduled task on the endpoint, which is what does the real work. Anything
|
||||
written to act "as the logged-on user" will not behave the way it reads.
|
||||
</div>
|
||||
|
||||
<details class="explainer">
|
||||
<summary>How does this work?</summary>
|
||||
<div class="explainer-body">
|
||||
<h4>Delivery</h4>
|
||||
<p>
|
||||
The body is stored here and hashed. At push time the agent downloads it
|
||||
with its own credential and checks the digest before running anything,
|
||||
so the bytes that run are the bytes that were reviewed. The body never
|
||||
travels on the command channel and is deleted from the endpoint after
|
||||
the run.
|
||||
</p>
|
||||
|
||||
<h4>Runs in</h4>
|
||||
<p>
|
||||
<strong>System</strong> is the agent's own service account: no desktop,
|
||||
no mapped drives, no signed-in person's credentials.
|
||||
<strong>User</strong> runs in the interactive session instead, and only
|
||||
works when somebody is actually signed in.
|
||||
</p>
|
||||
|
||||
<h4>Run in folder</h4>
|
||||
<p>
|
||||
The working directory for the run. Blank means the agent's staging
|
||||
folder, which is right for a self-contained script. Set it when the
|
||||
script calls a tool installed separately on the endpoint by relative
|
||||
path. If the folder is missing the run fails outright rather than
|
||||
running somewhere unintended.
|
||||
</p>
|
||||
|
||||
<h4>When a line fails</h4>
|
||||
<p>
|
||||
This is the one worth understanding. PowerShell treats most errors as
|
||||
<em>non-terminating</em>: it writes the error, carries on to the next
|
||||
line, and still exits zero. Because the command's verdict comes from
|
||||
that exit code, <strong>Continue</strong> means a script can report
|
||||
success having accomplished nothing.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Stop</strong> makes the first error end the run, so the verdict
|
||||
means what you would assume it means. It is not the default because it
|
||||
changes how a script behaves: a body that tolerates a failing cleanup,
|
||||
probes with a cmdlet that errors when something is absent, or loops
|
||||
past bad items will start aborting partway through, leaving the
|
||||
endpoint half-configured. Read the script first, then switch it on.
|
||||
</p>
|
||||
|
||||
<h4>Where the output goes</h4>
|
||||
<p>
|
||||
Script output is always written to the agent log on the endpoint. It is
|
||||
sent back to ShopDB only when the run fails, so routine output stays on
|
||||
the machine that produced it. Long output is truncated and marked as
|
||||
such.
|
||||
</p>
|
||||
<p class="hint">
|
||||
In Stop mode the agent adds one line to the top of the script, so an
|
||||
error reported on line N refers to line N-1 of the body shown here.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Runs in</th>
|
||||
<th>On error</th>
|
||||
<th>Body</th>
|
||||
<th>Digest</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in scripts" :key="s.scriptid">
|
||||
<td>{{ s.scriptname }}</td>
|
||||
<td class="cell-truncate" :title="s.scriptdescription">
|
||||
{{ s.scriptdescription || '-' }}
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.workingdirectory">
|
||||
{{ s.workingdirectory || 'Agent staging folder' }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.errormode === 'strict'" class="badge badge-secondary">
|
||||
Stop on first error
|
||||
</span>
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
<span v-else class="badge badge-warning">No body - cannot run</span>
|
||||
</td>
|
||||
<td class="cell-truncate mono" :title="s.sha256">{{ shortdigest(s) }}</td>
|
||||
<td>{{ s.isactive ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(s)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="remove(s)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="scripts.length === 0">
|
||||
<td colspan="8" style="text-align:center; color: var(--text-light);">
|
||||
No scripts
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit Script' : 'Add Script' }}</h3>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="warning-banner">
|
||||
<strong>This script will run as SYSTEM on every endpoint it is
|
||||
pushed to.</strong>
|
||||
Write it to trigger a scheduled task that already exists on the
|
||||
endpoint. It has no user session, so anything depending on a
|
||||
person being signed in will not work.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.scriptname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description <span class="hint">(what it triggers, and why)</span></label>
|
||||
<textarea v-model="form.scriptdescription" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Script file <span class="hint">(.ps1, replaces the stored body)</span>
|
||||
</label>
|
||||
<input type="file" accept=".ps1" class="form-control" @change="pickfile" />
|
||||
<p v-if="editing && editing.hasbody" class="hint">
|
||||
Current: {{ editing.filename }}. Uploading replaces it and
|
||||
restamps the digest.
|
||||
</p>
|
||||
<p v-else-if="editing" class="hint">
|
||||
No body uploaded yet. A script without a body cannot be pushed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
Run in folder
|
||||
<span class="hint">(optional, endpoint-local absolute path)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.workingdirectory"
|
||||
type="text"
|
||||
class="form-control mono"
|
||||
placeholder="C:\Program Files\CorporateTools"
|
||||
/>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
Leave blank to run from the agent's staging folder. Set this only
|
||||
when the script calls a tool already installed on the endpoint by
|
||||
relative path. The folder must exist on the endpoint or the push
|
||||
fails rather than running somewhere else.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
When a line fails
|
||||
<span class="hint">(how the run is judged)</span>
|
||||
</label>
|
||||
<select v-model="form.errormode" class="form-control">
|
||||
<option value="default">Continue - report success if the script reaches the end</option>
|
||||
<option value="strict">Stop on the first error and fail the command</option>
|
||||
</select>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
PowerShell normally writes an error and carries on, and still
|
||||
exits zero, so a script can report success having done none of
|
||||
its work. Stop makes the first failure end the run and fail the
|
||||
command. Leave this on Continue unless you have read the script
|
||||
and know it does not rely on a step being allowed to fail.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { scriptsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const scripts = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const pendingfile = ref(null)
|
||||
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
scriptname: '',
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
|
||||
function shortdigest(s) {
|
||||
return s.sha256 ? s.sha256.slice(0, 12) : '-'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await scriptsApi.list({ active: 'false' })
|
||||
scripts.value = response.data.data || []
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to load scripts'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickfile(event) {
|
||||
pendingfile.value = event.target.files[0] || null
|
||||
}
|
||||
|
||||
function openModal(script = null) {
|
||||
error.value = ''
|
||||
pendingfile.value = null
|
||||
editing.value = script
|
||||
form.value = script
|
||||
? {
|
||||
scriptname: script.scriptname,
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
pendingfile.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
scriptname: form.value.scriptname,
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
if (editing.value) {
|
||||
await scriptsApi.update(editing.value.scriptid, payload)
|
||||
scriptid = editing.value.scriptid
|
||||
} else {
|
||||
const created = await scriptsApi.create(payload)
|
||||
scriptid = created.data.data.scriptid
|
||||
}
|
||||
// Second call on purpose: the body is multipart and the catalog row is
|
||||
// JSON. A failure here leaves a bodyless row, which the list marks as
|
||||
// unrunnable rather than pretending it is ready to push.
|
||||
if (pendingfile.value) {
|
||||
await scriptsApi.uploadBody(scriptid, pendingfile.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save script')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s) {
|
||||
if (!confirm(`Delete script "${s.scriptname}"?`)) return
|
||||
try {
|
||||
await scriptsApi.delete(s.scriptid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cell-truncate {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mono { font-family: monospace; }
|
||||
.hint { color: var(--text-light); font-weight: normal; }
|
||||
.warning-banner {
|
||||
border: 1px solid var(--warning);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
}
|
||||
.explainer {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body {
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
.explainer-body h4 {
|
||||
margin: 0.9rem 0 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.explainer-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-light);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-help {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.field-help summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field-help p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
124
plugins/endpoints/frontend/views/systemScriptsSettings.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* System Scripts settings page.
|
||||
*
|
||||
* Two things are worth pinning here and nothing else is:
|
||||
*
|
||||
* 1. The page must NOT offer a run context. Scripts run as SYSTEM because the
|
||||
* agent is a service with no user session; exposing a picker would let
|
||||
* someone author a script that reads as "run as the logged-on user" and
|
||||
* silently does not.
|
||||
* 2. The SYSTEM warning must be present on both the list and the add/edit
|
||||
* form. Someone pasting a script into the modal may never have read the
|
||||
* page copy above it.
|
||||
*
|
||||
* Asserted against the file text, matching configurationPolicyEditable.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'SystemScriptsSettings.vue'), 'utf8')
|
||||
const routes = readFileSync(join(here, '..', 'routes.js'), 'utf8')
|
||||
|
||||
describe('SystemScriptsSettings', () => {
|
||||
it('warns that scripts run as SYSTEM on the list page and in the form', () => {
|
||||
const warnings = source.match(/runs? as SYSTEM/g) || []
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('does not offer a run context picker', () => {
|
||||
expect(source).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('does not send a runcontext in its save payload', () => {
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 200)).not.toMatch(/runcontext/i)
|
||||
})
|
||||
|
||||
it('flags a script with no body as unrunnable', () => {
|
||||
expect(source).toMatch(/hasbody/)
|
||||
expect(source).toMatch(/cannot run/i)
|
||||
})
|
||||
|
||||
it('is gated on the standalone script permission, not admin', () => {
|
||||
const route = routes.slice(routes.indexOf("'settings/endpoints/scripts'"))
|
||||
expect(route).toMatch(/requiresPermission: 'endpoints\.runscript'/)
|
||||
expect(route.slice(0, 300)).not.toMatch(/requiresAdmin/)
|
||||
})
|
||||
|
||||
it('offers an optional working directory and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.workingdirectory/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/workingdirectory/)
|
||||
})
|
||||
|
||||
it('shows the staging default rather than an empty cell', () => {
|
||||
// A blank cell reads as "unknown". The folder is the difference between a
|
||||
// corporate tool being found and the script failing further in, so the
|
||||
// list has to state the default out loud.
|
||||
expect(source).toMatch(/Agent staging folder/)
|
||||
})
|
||||
|
||||
it('offers an error mode and sends it on save', () => {
|
||||
expect(source).toMatch(/form\.errormode/)
|
||||
const payload = source.slice(source.indexOf('const payload = {'))
|
||||
expect(payload.slice(0, 300)).toMatch(/errormode/)
|
||||
})
|
||||
|
||||
it('defaults a new script to the permissive mode', () => {
|
||||
// Strict changes how a body behaves partway through. Defaulting to it
|
||||
// would silently alter every script authored from here on, so a new
|
||||
// script has to start where the old ones already are.
|
||||
const empty = source.slice(source.indexOf('function emptyForm'))
|
||||
expect(empty.slice(0, 300)).toMatch(/errormode: 'default'/)
|
||||
})
|
||||
|
||||
it('names both error modes by their consequence, not by their flag', () => {
|
||||
// "default" and "strict" mean nothing to the person choosing. What they
|
||||
// need to know is which one can report success having done nothing.
|
||||
expect(source).toMatch(/Continue - report success if the script reaches the end/)
|
||||
expect(source).toMatch(/Stop on the first error and fail the command/)
|
||||
})
|
||||
|
||||
it('explains the non-terminating error trap that justifies the choice', () => {
|
||||
// The whole feature exists because PowerShell exits zero after an error.
|
||||
// If the page never says so, the dropdown looks like pedantry and stays
|
||||
// on the setting that produces false green.
|
||||
expect(source).toMatch(/non-terminating/i)
|
||||
expect(source).toMatch(/exits? zero/i)
|
||||
})
|
||||
|
||||
it('offers the explainer collapsed so it does not bury the page', () => {
|
||||
expect(source).toMatch(/<details class="explainer">/)
|
||||
expect(source).toMatch(/How does this work\?/)
|
||||
expect(source).not.toMatch(/<details class="explainer" open>/)
|
||||
})
|
||||
|
||||
it('collapses the long per-field prose in the form', () => {
|
||||
// The modal is already tall enough to scroll. Three paragraphs of standing
|
||||
// explanation between the inputs pushes Save off screen and gets skimmed
|
||||
// past, which is the opposite of what the copy is for.
|
||||
const helps = source.match(/<details class="field-help">/g) || []
|
||||
expect(helps.length).toBeGreaterThanOrEqual(2)
|
||||
expect(source).toMatch(/<summary>How this works<\/summary>/)
|
||||
})
|
||||
|
||||
it('styles itself from defined theme tokens only', () => {
|
||||
// --surface and --text-primary are not defined by this app's theme, so a
|
||||
// var() fallback silently rendered a white panel on the dark background.
|
||||
// A fallback colour is the bug: it looks correct in light mode and only
|
||||
// shows up as a bright block for dark-mode users.
|
||||
const styles = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(styles).not.toMatch(/--surface|--text-primary|--text-secondary|--bg-secondary/)
|
||||
expect(styles).not.toMatch(/var\(--[a-z-]+,\s*#/)
|
||||
})
|
||||
|
||||
it('states that output is kept locally and only returned on failure', () => {
|
||||
// Operators otherwise assume either that nothing was captured or that
|
||||
// every run is shipped back to the server.
|
||||
const explainer = source.slice(source.indexOf('Where the output goes'))
|
||||
expect(explainer.slice(0, 400)).toMatch(/only when the run fails/i)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
|
||||
process agent.exe -> MTConnect
|
||||
service MTConnect UDC Agent -> MTConnect
|
||||
|
||||
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
|
||||
or delete them. Launch paths mirror the agent's launchpaths block; collector and
|
||||
Shipped rows (source='shipped') keep their kind and displayname - that pair is
|
||||
the identity the monitor policies match on - but a site may edit their paths and
|
||||
active flag. Launch paths mirror the agent's launchpaths block; collector and
|
||||
log paths are left null until site paths are supplied.
|
||||
|
||||
Idempotent: creation checks the live schema first so a db.create_all() site
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Ship the shopfloor app monitors as a read-only Site Policy.
|
||||
"""Ship the shopfloor app monitors as a Site Policy bundle.
|
||||
|
||||
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
|
||||
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
|
||||
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
|
||||
sites compose their own. Read this for history; do not extend it.
|
||||
|
||||
Two claims below were true when written and are now false, corrected here rather
|
||||
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
|
||||
at the policy CRUD guards), and no seeder reconciles them - the idempotency
|
||||
check at the top of upgrade() returns early if the policy exists, which is what
|
||||
keeps a site's edits safe across upgrades.
|
||||
|
||||
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
|
||||
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
|
||||
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
|
||||
its watch-list from the server instead.
|
||||
MTConnect process, MTConnect UDC Agent service) in a policy bundle so the agent
|
||||
stops carrying them as hardcoded appsettings.json defaults and reads its
|
||||
watch-list from the server instead.
|
||||
|
||||
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
|
||||
CRUD API refuses to edit or delete them):
|
||||
Seeds two computers-plugin rows, both source='shipped':
|
||||
|
||||
ConfigurationPolicy "Shopfloor App Monitors"
|
||||
scope {"type":"true"} (every endpoint), one action per shipped monitor,
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Split the shipped monitor bundle into one policy per monitor.
|
||||
|
||||
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
|
||||
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
|
||||
same name. That bundle was all-or-nothing: a site that runs eDNC but no
|
||||
MTConnect could not detach the half it does not have, because the whole row was
|
||||
product-owned and read-only.
|
||||
|
||||
This migration replaces it with one Configuration Policy per shipped monitor:
|
||||
|
||||
Monitor: eDNC (process)
|
||||
Monitor: Universal Data Collector (process)
|
||||
Monitor: MTConnect (process)
|
||||
Monitor: MTConnect (service)
|
||||
|
||||
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
|
||||
scope {"type":"true"} - no PC type attribution. The name carries the kind
|
||||
because displayname alone is not unique: MTConnect ships as both a process and
|
||||
a service, and configurationpolicies.name is UNIQUE.
|
||||
|
||||
source stays 'shipped'. Provenance is still true - the product did ship these -
|
||||
and the UI keeps showing where they came from. What changes is that the CRUD
|
||||
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
|
||||
without losing the record of its origin.
|
||||
|
||||
The shipped SITE policy is REMOVED and not replaced. Composition is a site
|
||||
decision: which monitors apply to which endpoints, and at what priority, is
|
||||
exactly the judgement a plant makes about its own floor. Shipping one meant
|
||||
every install inherited "apply all four to every endpoint" as a starting point
|
||||
that could not be edited. Sites now build their own from the per-monitor pieces.
|
||||
|
||||
Removing it is the behaviour change to be aware of on upgrade: monitors that
|
||||
were attached through that bundle stop being attached until a site policy
|
||||
composes them. It is deliberate and it is the point of the split - see the
|
||||
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
|
||||
the per-monitor policies are listed and ready to compose.
|
||||
|
||||
Idempotent on the policy names, so a re-run neither duplicates the split rows
|
||||
nor resurrects the bundle. Guards on the policy tables being present and no-ops
|
||||
if they are not, matching 0007.
|
||||
|
||||
Revision ID: endpoints0019splitpolicies
|
||||
Revises: endpoints0018endpointlocks
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Alembic stores this in alembic_version.version_num, a VARCHAR(32). A longer
|
||||
# id passes on SQLite and fails on strict MySQL with "Data too long".
|
||||
revision = 'endpoints0019splitpolicies'
|
||||
down_revision = 'endpoints0018endpointlocks'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
|
||||
# so this survives id drift across installs exactly as 0007 does.
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
|
||||
'configurationpolicyactions', 'businesspolicies',
|
||||
'businesspolicyitems'}
|
||||
|
||||
|
||||
def _tables(bind):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
return set(inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _policy_description(kind, displayname):
|
||||
noun = 'process' if kind == 'process' else 'service'
|
||||
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
|
||||
f'Attach it to a Site Policy to apply it to endpoints.')
|
||||
|
||||
|
||||
def _config_policy_id(bind, name):
|
||||
return bind.execute(
|
||||
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': name}).scalar()
|
||||
|
||||
|
||||
def _drop_config_policy(bind, name):
|
||||
"""Delete a configuration policy, its actions, and any items citing it."""
|
||||
policyid = _config_policy_id(bind, name)
|
||||
if policyid is None:
|
||||
return
|
||||
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
|
||||
# sqlite does not enforce by default.
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM configurationpolicies '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid})
|
||||
|
||||
|
||||
def _drop_business_policy(bind, name):
|
||||
policyid = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name AND source = :source'),
|
||||
{'name': name, 'source': 'shipped'}).scalar()
|
||||
if policyid is None:
|
||||
return
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
bind.execute(
|
||||
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
|
||||
{'id': policyid})
|
||||
|
||||
|
||||
def _monitor_id(bind, kind, displayname):
|
||||
return bind.execute(
|
||||
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
if _config_policy_id(bind, name) is not None:
|
||||
continue
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is None:
|
||||
# A site that never had this monitor gets no policy for it.
|
||||
continue
|
||||
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': name,
|
||||
'description': _policy_description(kind, displayname),
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': _config_policy_id(bind, name),
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
|
||||
# Retire the monolith. The business policy goes first so its item rows are
|
||||
# gone before the configuration policy it cited is removed.
|
||||
_drop_business_policy(bind, BUNDLE_NAME)
|
||||
_drop_config_policy(bind, BUNDLE_NAME)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Reinstate the single shipped bundle and drop the per-monitor policies.
|
||||
|
||||
Rebuilt from the same monitor identities rather than from anything the
|
||||
upgrade stashed, so it lands in the state 0007 produces regardless of what
|
||||
a site did to the split rows in between.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
if not _REQUIRED.issubset(_tables(bind)):
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
monitor_ids = []
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
monitorid = _monitor_id(bind, kind, displayname)
|
||||
if monitorid is not None:
|
||||
monitor_ids.append(monitorid)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
_drop_config_policy(bind, _policy_name(kind, displayname))
|
||||
|
||||
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
|
||||
return
|
||||
|
||||
config_policies = sa.table(
|
||||
'configurationpolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('scopejson', sa.Text),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(config_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped watch-list for the shopfloor apps '
|
||||
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
|
||||
'scopejson': '{"type": "true"}',
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
config_policy_id = _config_policy_id(bind, BUNDLE_NAME)
|
||||
|
||||
actions = sa.table(
|
||||
'configurationpolicyactions',
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('action', sa.String),
|
||||
sa.column('mode', sa.String),
|
||||
sa.column('desiredstate', sa.Text),
|
||||
sa.column('params', sa.Text),
|
||||
sa.column('intervalminutes', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(actions, [{
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'action': f'monitor:{monitorid}',
|
||||
'mode': 'gate',
|
||||
'desiredstate': None,
|
||||
'params': None,
|
||||
'intervalminutes': None,
|
||||
'sortorder': index,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
} for index, monitorid in enumerate(monitor_ids)])
|
||||
|
||||
business_policies = sa.table(
|
||||
'businesspolicies',
|
||||
sa.column('name', sa.String),
|
||||
sa.column('description', sa.Text),
|
||||
sa.column('priority', sa.Integer),
|
||||
sa.column('source', sa.String),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(business_policies, [{
|
||||
'name': BUNDLE_NAME,
|
||||
'description': 'Product-shipped Site Policy that applies the shopfloor '
|
||||
'app monitors to every endpoint. Read-only.',
|
||||
'priority': 100,
|
||||
'source': 'shipped',
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
business_policy_id = bind.execute(
|
||||
sa.text('SELECT businesspolicyid FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
|
||||
items = sa.table(
|
||||
'businesspolicyitems',
|
||||
sa.column('businesspolicyid', sa.Integer),
|
||||
sa.column('configurationpolicyid', sa.Integer),
|
||||
sa.column('sortorder', sa.Integer),
|
||||
sa.column('createddate', sa.DateTime),
|
||||
sa.column('modifieddate', sa.DateTime),
|
||||
sa.column('isactive', sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(items, [{
|
||||
'businesspolicyid': business_policy_id,
|
||||
'configurationpolicyid': config_policy_id,
|
||||
'sortorder': 0,
|
||||
'createddate': now,
|
||||
'modifieddate': now,
|
||||
'isactive': True,
|
||||
}])
|
||||
@@ -320,6 +320,21 @@ class EndpointsPlugin(BasePlugin):
|
||||
'agent, and configure what it reports.',
|
||||
'position': 27,
|
||||
},
|
||||
# Its own card rather than a section of the agent key page: the
|
||||
# audience differs. Key management is admin-only, while the script
|
||||
# library is reachable by any endpoints.runscript holder, and
|
||||
# burying it under an admin page would hide it from exactly the
|
||||
# operators the grant exists for.
|
||||
{
|
||||
'group': 'Endpoints',
|
||||
'to': '/settings/endpoints/scripts',
|
||||
'icon': 'terminal',
|
||||
'title': 'System Scripts',
|
||||
'description': 'PowerShell the agent can run on an endpoint. '
|
||||
'Runs as SYSTEM; used to trigger scheduled '
|
||||
'tasks already registered on the endpoint.',
|
||||
'position': 28,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
|
||||
@@ -6,16 +6,33 @@ its body by digest on push (the same model as application installer delivery).
|
||||
The body never crosses the command wire and is never pre-staged on the endpoint;
|
||||
the agent downloads it with its collector credential and verifies sha256.
|
||||
|
||||
Gated on the applications.* permissions - scripts are software delivery like
|
||||
apps, so they ride the same operator grant rather than a separate permission.
|
||||
Gated on endpoints.runscript, the same standalone grant that authorizes pushing
|
||||
a script. These are NOT applications: an application installer is a vendor
|
||||
artefact, while a script here is arbitrary PowerShell this site wrote that the
|
||||
agent runs as SYSTEM. Gating library management on applications.* meant anyone
|
||||
with the software-delivery grant could rewrite the bytes of a script a trusted
|
||||
operator later pushes, which is a privilege escalation wearing a content-
|
||||
management hat. The write and the push now require the same trust.
|
||||
|
||||
This TIGHTENS access, and there is deliberately NO backfill migration. The
|
||||
operator scope grants applications.create/edit (they are edit verbs), so
|
||||
operator-scope holders could reach this library before and cannot now. Do not
|
||||
"fix" that by granting endpoints.runscript to applications.edit holders on
|
||||
upgrade: it is in SCOPE_NEVER_GRANT precisely because it is a per-user checkbox
|
||||
decision, and the same grant also authorizes the remote PUSH, so a backfill
|
||||
would hand every operator SYSTEM code execution on the fleet. Admins are
|
||||
unaffected via the role bypass; anyone else who genuinely needs the library
|
||||
gets the checkbox in the user editor.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
|
||||
from shopdb.core.models import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
|
||||
ERRORMODES, ERRORMODE_DEFAULT, AuditLog)
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
@@ -32,6 +49,11 @@ SCRIPT_EXTENSIONS = {'.ps1'}
|
||||
SCRIPT_URL_PREFIX = '/api/scripts/body/'
|
||||
# A delivery script is small; cap well under the installer limit.
|
||||
MAX_SCRIPT_BYTES = 1 * 1024 * 1024
|
||||
# Owned by the endpoints plugin, which is where the risk is described in full
|
||||
# ("ARBITRARY CODE, executes as SYSTEM"). Core names it as a string rather than
|
||||
# importing it so this module stays importable with the plugin absent - the
|
||||
# grant simply cannot be held by anyone if nothing seeds it.
|
||||
SCRIPT_PERMISSION = 'endpoints.runscript'
|
||||
|
||||
|
||||
def _script_dir():
|
||||
@@ -42,7 +64,7 @@ scripts_bp = Blueprint('scripts', __name__)
|
||||
|
||||
|
||||
@scripts_bp.route('', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def list_scripts():
|
||||
"""List scripts (active by default)."""
|
||||
query = Script.query
|
||||
@@ -55,7 +77,7 @@ def list_scripts():
|
||||
|
||||
|
||||
@scripts_bp.route('/<int:script_id>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def get_script(script_id: int):
|
||||
script = db.session.get(Script, script_id)
|
||||
if not script:
|
||||
@@ -73,8 +95,71 @@ def _validate_runcontext(value):
|
||||
return value, None
|
||||
|
||||
|
||||
def _validate_errormode(value):
|
||||
"""Normalize/validate an errormode, defaulting to permissive when absent.
|
||||
|
||||
Defaults to ERRORMODE_DEFAULT rather than strict on purpose. Strict promotes
|
||||
PowerShell's non-terminating errors to terminating ones, so a body that was
|
||||
written to tolerate a failing cleanup or to probe with a cmdlet that errors
|
||||
when the target is absent would begin aborting partway through. An absent
|
||||
field therefore means "what this script already did", never a silent upgrade.
|
||||
"""
|
||||
if value is None:
|
||||
return ERRORMODE_DEFAULT, None
|
||||
value = str(value).strip().lower()
|
||||
if value not in ERRORMODES:
|
||||
return None, 'errormode must be one of: {}'.format(', '.join(sorted(ERRORMODES)))
|
||||
return value, None
|
||||
|
||||
|
||||
def _validate_workingdirectory(value):
|
||||
"""Normalize the optional folder a script runs in. Returns (value, error).
|
||||
|
||||
Empty means "the agent's staging directory", which is the behaviour every
|
||||
script had before this field existed. The purpose is to let a script sit
|
||||
beside a corporate tool already installed on the endpoint and call it by
|
||||
relative path.
|
||||
|
||||
The checks are shape checks, NOT a privilege boundary - a script body is
|
||||
already arbitrary SYSTEM PowerShell and can change its own directory. They
|
||||
exist so a typo fails here with an explanation instead of on the endpoint:
|
||||
|
||||
- must be an absolute local path (a drive letter), because a relative one
|
||||
is meaningless without knowing what it would be relative TO
|
||||
- no UNC. The stated use case is a tool already on this machine; a UNC
|
||||
working directory resolves as the MACHINE ACCOUNT against a file server,
|
||||
which moves where the binaries come from and is a different trust story
|
||||
than "Intune put this here"
|
||||
- no traversal segment, which only ever obscures what the path really is
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
value = str(value).strip().strip('"')
|
||||
if not value:
|
||||
return None, None
|
||||
if value.startswith('\\\\') or value.startswith('//'):
|
||||
return None, ('workingdirectory must be a local path on the endpoint, '
|
||||
'not a UNC share')
|
||||
normalized = value.replace('/', '\\')
|
||||
if not re.match(r'^[A-Za-z]:\\', normalized):
|
||||
return None, ('workingdirectory must be an absolute path including a '
|
||||
r'drive letter, for example C:\Program Files\Sysinternals')
|
||||
if '..' in normalized.split('\\'):
|
||||
return None, 'workingdirectory must not contain a ".." segment'
|
||||
if len(normalized) > 255:
|
||||
return None, 'workingdirectory must be 255 characters or fewer'
|
||||
# Trailing separator is noise, EXCEPT at a drive root: "C:\" trimmed to
|
||||
# "C:" is drive-RELATIVE on Windows and would resolve to wherever the
|
||||
# process happened to be on that drive, which is the opposite of pinning a
|
||||
# folder. Keep the root form intact.
|
||||
trimmed = normalized.rstrip('\\')
|
||||
if len(trimmed) < 3:
|
||||
trimmed = normalized[:3]
|
||||
return trimmed, None
|
||||
|
||||
|
||||
@scripts_bp.route('', methods=['POST'])
|
||||
@require_permission('applications.create')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def create_script():
|
||||
"""Create a script catalog entry (body uploaded separately)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -85,6 +170,12 @@ def create_script():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'A script named {name!r} already exists')
|
||||
runcontext, err = _validate_runcontext(data.get('runcontext'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
workingdirectory, err = _validate_workingdirectory(data.get('workingdirectory'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
errormode, err = _validate_errormode(data.get('errormode'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
@@ -92,18 +183,22 @@ def create_script():
|
||||
scriptname=name,
|
||||
scriptdescription=data.get('scriptdescription'),
|
||||
runcontext=runcontext,
|
||||
workingdirectory=workingdirectory,
|
||||
errormode=errormode,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'Script', entityid=script.scriptid,
|
||||
entityname=name,
|
||||
changes={'runcontext': {'new': runcontext}})
|
||||
changes={'runcontext': {'new': runcontext},
|
||||
'workingdirectory': {'new': workingdirectory},
|
||||
'errormode': {'new': errormode}})
|
||||
db.session.commit()
|
||||
return success_response(script.to_dict(), message='Script created', http_code=201)
|
||||
|
||||
|
||||
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
|
||||
@require_permission('applications.edit')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def update_script(script_id: int):
|
||||
script = db.session.get(Script, script_id)
|
||||
if not script:
|
||||
@@ -127,6 +222,17 @@ def update_script(script_id: int):
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.runcontext = runcontext
|
||||
if 'workingdirectory' in data:
|
||||
workingdirectory, err = _validate_workingdirectory(
|
||||
data.get('workingdirectory'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.workingdirectory = workingdirectory
|
||||
if 'errormode' in data:
|
||||
errormode, err = _validate_errormode(data.get('errormode'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.errormode = errormode
|
||||
if 'isactive' in data:
|
||||
script.isactive = bool(data.get('isactive'))
|
||||
|
||||
@@ -135,7 +241,7 @@ def update_script(script_id: int):
|
||||
|
||||
|
||||
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
|
||||
@require_permission('applications.delete')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def delete_script(script_id: int):
|
||||
script = db.session.get(Script, script_id)
|
||||
if not script:
|
||||
@@ -149,7 +255,7 @@ def delete_script(script_id: int):
|
||||
|
||||
|
||||
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
|
||||
@require_permission('applications.edit')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def upload_script_body(script_id: int):
|
||||
"""Upload (or replace) the .ps1 body for a script.
|
||||
|
||||
@@ -196,15 +302,15 @@ def upload_script_body(script_id: int):
|
||||
|
||||
|
||||
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.download')
|
||||
@require_permission(SCRIPT_PERMISSION)
|
||||
def download_script_body(filename: str):
|
||||
"""Operator-facing script body download (JWT/RBAC).
|
||||
|
||||
The endpoint agent uses the collector-authenticated plugin route instead;
|
||||
this one is for humans reviewing what a script does.
|
||||
|
||||
Gated on applications.download, not applications.view: this serves the
|
||||
SYSTEM-context PowerShell a site pushes to its bays, and a guest may be
|
||||
granted the metadata permission.
|
||||
Same grant as the rest of the library: this serves the SYSTEM-context
|
||||
PowerShell a site pushes to its bays, so reading it is not a lesser act
|
||||
than listing it.
|
||||
"""
|
||||
return send_upload(_script_dir(), filename, as_attachment=True)
|
||||
|
||||
@@ -19,7 +19,8 @@ from .user import (User, Role, Permission, full_permission_catalog,
|
||||
SCOPE_NEVER_GRANT, SCOPES_DENIED_ENDPOINT_ACTIONS)
|
||||
from .application import Application, AppVersion
|
||||
from .script_library import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
|
||||
RUNCONTEXT_USER)
|
||||
RUNCONTEXT_USER, ERRORMODES, ERRORMODE_DEFAULT,
|
||||
ERRORMODE_STRICT)
|
||||
from .supportteam import SupportTeam, SupportTeamContact
|
||||
from .setting import Setting
|
||||
from .auditlog import AuditLog
|
||||
@@ -78,6 +79,9 @@ __all__ = [
|
||||
'RUNCONTEXTS',
|
||||
'RUNCONTEXT_SYSTEM',
|
||||
'RUNCONTEXT_USER',
|
||||
'ERRORMODES',
|
||||
'ERRORMODE_DEFAULT',
|
||||
'ERRORMODE_STRICT',
|
||||
# Support teams
|
||||
'SupportTeam',
|
||||
'SupportTeamContact',
|
||||
|
||||
@@ -16,6 +16,26 @@ RUNCONTEXT_SYSTEM = 'system'
|
||||
RUNCONTEXT_USER = 'user'
|
||||
RUNCONTEXTS = (RUNCONTEXT_SYSTEM, RUNCONTEXT_USER)
|
||||
|
||||
# How the agent treats a PowerShell NON-TERMINATING error.
|
||||
#
|
||||
# DEFAULT is what PowerShell does on its own: the error is written, the script
|
||||
# keeps going, and powershell.exe still exits 0. That is why a script could
|
||||
# report succeeded while doing none of its work - the exit code answers "did
|
||||
# the interpreter reach the end", not "did the script do its job".
|
||||
#
|
||||
# STRICT prepends $ErrorActionPreference = 'Stop' so the first error terminates
|
||||
# and the exit code becomes non-zero, which makes the reported verdict mean
|
||||
# what an operator assumes it means.
|
||||
#
|
||||
# Default is DEFAULT, deliberately. Strict changes execution semantics: a body
|
||||
# that tolerates a failing Remove-Item, probes with Get-LocalUser, or loops past
|
||||
# a bad item would start aborting partway. Existing scripts were authored under
|
||||
# the permissive rule, so this is opt-in per script AFTER the body has been read
|
||||
# rather than a library-wide switch flipped on code nobody re-checked.
|
||||
ERRORMODE_DEFAULT = 'default'
|
||||
ERRORMODE_STRICT = 'strict'
|
||||
ERRORMODES = (ERRORMODE_DEFAULT, ERRORMODE_STRICT)
|
||||
|
||||
|
||||
class Script(BaseModel):
|
||||
"""ShopDB-hosted PowerShell script the endpoint agent runs on push."""
|
||||
@@ -32,6 +52,23 @@ class Script(BaseModel):
|
||||
# 'system' or 'user' (see RUNCONTEXTS). Per-script so a drive-mapping script
|
||||
# can target the user session while a machine-wide script runs as SYSTEM.
|
||||
runcontext = db.Column(db.String(16), default=RUNCONTEXT_SYSTEM, nullable=False)
|
||||
# Optional folder to run the script IN. Empty means the agent's staging
|
||||
# directory, which is the right default for a self-contained script and the
|
||||
# wrong one for a script that shells out to a corporate tool delivered by
|
||||
# Intune (Sysinternals, vendor CLIs) and referenced by relative path.
|
||||
#
|
||||
# This grants NOTHING. The body is already arbitrary PowerShell the agent
|
||||
# runs as SYSTEM, and it could Set-Location anywhere itself; the security
|
||||
# boundary is endpoints.runscript plus the digest-pinned library, not the
|
||||
# working directory. Recorded here so this is not later mistaken for a
|
||||
# privilege surface and gated harder than it needs to be.
|
||||
workingdirectory = db.Column(db.String(255))
|
||||
# 'default' or 'strict' (see ERRORMODES). Opt-in per script because strict
|
||||
# turns non-terminating errors fatal and would change how existing bodies
|
||||
# run. Not nullable with a default so a row written before this column
|
||||
# existed reads as the permissive behavior it was authored under.
|
||||
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
|
||||
server_default=ERRORMODE_DEFAULT)
|
||||
|
||||
def to_dict(self):
|
||||
result = super().to_dict()
|
||||
|
||||
138
tests/test_core/test_script_errormode.py
Normal file
138
tests/test_core/test_script_errormode.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""The per-script error mode.
|
||||
|
||||
PowerShell treats most errors as non-terminating: it writes the error, runs the
|
||||
next line, and still exits zero. The agent judges a run by that exit code, so a
|
||||
script could report success having accomplished none of its work - which is
|
||||
exactly what happened with the account-provisioning script that "succeeded"
|
||||
without provisioning anything.
|
||||
|
||||
Strict mode fixes the verdict by making the first error terminating. It is
|
||||
deliberately NOT the default, because it changes how an existing body behaves
|
||||
partway through: a script that tolerates a failing cleanup, probes with a cmdlet
|
||||
that errors when something is absent, or loops past bad items would start
|
||||
aborting midway and leave the endpoint half-configured. These tests pin the
|
||||
permissive default hard, since that is the property protecting every script
|
||||
authored before this field existed.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='modeauthor',
|
||||
email='modeauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'modeauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_a_mode_stays_permissive(client, headers):
|
||||
"""The load-bearing test. Every script written before this field existed
|
||||
was authored against permissive semantics; defaulting to strict would
|
||||
change how all of them run without anyone touching them."""
|
||||
created = _create(client, headers, 'no-mode')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_strict_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'strict-one', errormode='strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', ['stop', 'true', 'yes', 'none', ''])
|
||||
def test_an_unrecognised_mode_is_rejected(client, headers, bad):
|
||||
"""Rejected rather than coerced. Silently falling back to permissive would
|
||||
let a caller believe a script fails loudly when it does not, which is the
|
||||
precise failure this field exists to remove."""
|
||||
created = _create(client, headers, f'bad-{bad or "blank"}', errormode=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'errormode' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_casing_is_normalised_rather_than_refused(client, headers):
|
||||
"""A caller typing Strict means strict. Case is a keyboard artefact, not a
|
||||
different intention, so it is normalised - unlike the values above, which
|
||||
are genuinely different words and could mean something the author did not
|
||||
intend."""
|
||||
created = _create(client, headers, 'cased', errormode='Strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_a_script_can_be_moved_back_to_permissive(client, headers):
|
||||
"""Strict has to be reversible without recreating the script: the way you
|
||||
discover a body cannot tolerate it is by watching it abort in production."""
|
||||
scriptid = _create(client, headers, 'reversible',
|
||||
errormode='strict').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'errormode': 'default'})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even at the default, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-strict', filename='script-1.ps1',
|
||||
sha256='a' * 64, errormode='strict')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_the_descriptor_defaults_rather_than_omitting(client, db, headers):
|
||||
"""An absent key would leave the agent guessing. It sends the permissive
|
||||
value explicitly so both ends agree on what unset means."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-default', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'default'
|
||||
160
tests/test_core/test_script_library_gate.py
Normal file
160
tests/test_core/test_script_library_gate.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""The script library is gated on its own grant, not on applications.*.
|
||||
|
||||
A script here is not an application. An installer is a vendor artefact; a script
|
||||
is arbitrary PowerShell this site wrote that the agent runs as SYSTEM. While the
|
||||
library was gated on applications.*, anyone holding the software-delivery grant
|
||||
could rewrite the bytes of a script a trusted operator later pushed - the push
|
||||
was gated on endpoints.runscript, but the CONTENT of what got pushed was not.
|
||||
That is privilege escalation wearing a content-management hat.
|
||||
|
||||
These tests pin the gate itself. The library had NO coverage before this.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
APPLICATIONS_GRANTS = [
|
||||
('applications.view', 'View applications', 'applications'),
|
||||
('applications.create', 'Create applications', 'applications'),
|
||||
('applications.edit', 'Edit applications', 'applications'),
|
||||
('applications.delete', 'Delete applications', 'applications'),
|
||||
('applications.download', 'Download installers', 'applications'),
|
||||
]
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(db):
|
||||
Permission.seed_entries(APPLICATIONS_GRANTS + [SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _user_with(client, db, username, grants):
|
||||
"""Create a user holding exactly the named permissions, return its headers.
|
||||
|
||||
Direct permissions, no role: a role named 'admin' would short-circuit the
|
||||
gate and prove nothing about the permission under test.
|
||||
"""
|
||||
from shopdb.core.models import Permission, User
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
for name in grants:
|
||||
permission = Permission.query.filter_by(name=name).one()
|
||||
user.directpermissions.append(permission)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
token = login.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def test_the_applications_grant_does_not_open_the_script_library(
|
||||
client, db, seeded):
|
||||
"""The whole point: every applications.* grant, and still no access."""
|
||||
headers = _user_with(client, db, 'appsonly',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
|
||||
assert client.get('/api/scripts', headers=headers).status_code == 403
|
||||
assert client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'x'}).status_code == 403
|
||||
|
||||
|
||||
def test_the_runscript_grant_opens_the_script_library(client, db, seeded):
|
||||
headers = _user_with(client, db, 'pusher', ['endpoints.runscript'])
|
||||
|
||||
assert client.get('/api/scripts', headers=headers).status_code == 200
|
||||
created = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'map-drives'})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
|
||||
|
||||
def test_writing_a_script_body_needs_the_same_grant(client, db, seeded):
|
||||
"""Uploading the bytes is the dangerous half; it must not be cheaper to
|
||||
reach than the push that runs them."""
|
||||
author = _user_with(client, db, 'author', ['endpoints.runscript'])
|
||||
created = client.post('/api/scripts', headers=author,
|
||||
json={'scriptname': 'payload'})
|
||||
scriptid = created.get_json()['data']['scriptid']
|
||||
|
||||
appsuser = _user_with(client, db, 'appsuser2',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
body = (io.BytesIO(b"Write-Output 'hi'\r\n"), 'payload.ps1')
|
||||
denied = client.post(f'/api/scripts/{scriptid}/body', headers=appsuser,
|
||||
data={'file': body},
|
||||
content_type='multipart/form-data')
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_reading_a_script_body_needs_the_same_grant(client, db, seeded):
|
||||
"""Reading is not a lesser act: the body is the SYSTEM-context PowerShell
|
||||
itself, so it must not be readable by a weaker grant than writing it."""
|
||||
appsuser = _user_with(client, db, 'appsuser3',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
response = client.get('/api/scripts/body/script-1.ps1', headers=appsuser)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_upload_pins_the_digest_of_the_stored_bytes(client, db, seeded):
|
||||
"""The agent refuses a body whose sha256 does not match, so a wrong digest
|
||||
here is a script that can never run. Untested until now."""
|
||||
import hashlib
|
||||
|
||||
headers = _user_with(client, db, 'digest', ['endpoints.runscript'])
|
||||
created = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'digest-check'})
|
||||
scriptid = created.get_json()['data']['scriptid']
|
||||
|
||||
payload = b"Write-Output 'pinned'\r\n"
|
||||
uploaded = client.post(
|
||||
f'/api/scripts/{scriptid}/body', headers=headers,
|
||||
data={'file': (io.BytesIO(payload), 'digest-check.ps1')},
|
||||
content_type='multipart/form-data')
|
||||
assert uploaded.status_code == 200, uploaded.get_json()
|
||||
|
||||
data = uploaded.get_json()['data']
|
||||
assert data['sha256'] == hashlib.sha256(payload).hexdigest()
|
||||
assert data['hasbody'] is True
|
||||
|
||||
|
||||
def test_a_duplicate_script_name_is_refused(client, db, seeded):
|
||||
"""The accidental-overwrite guard the library was specified to have."""
|
||||
headers = _user_with(client, db, 'dupes', ['endpoints.runscript'])
|
||||
first = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'same-name'})
|
||||
assert first.status_code == 201
|
||||
second = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'same-name'})
|
||||
assert second.status_code == 400
|
||||
assert 'already exists' in second.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_no_prebuilt_scope_confers_the_script_permission():
|
||||
"""Pins the decision NOT to backfill the tightened gate.
|
||||
|
||||
Moving the library onto endpoints.runscript takes it away from operator
|
||||
scope holders, who reach applications.create/edit. The tempting upgrade fix
|
||||
is to grant endpoints.runscript to whoever holds applications.edit - that
|
||||
would undo the whole change AND hand every operator remote SYSTEM code
|
||||
execution, because the same grant gates the push. If this test fails,
|
||||
someone has widened a scope; do not relax it.
|
||||
"""
|
||||
from shopdb.core.models.user import (
|
||||
SCOPE_NEVER_GRANT,
|
||||
scope_permission_names,
|
||||
)
|
||||
from shopdb.core.api.scripts import SCRIPT_PERMISSION
|
||||
|
||||
assert SCRIPT_PERMISSION in SCOPE_NEVER_GRANT
|
||||
for scope in ('reporter', 'technician', 'operator'):
|
||||
assert SCRIPT_PERMISSION not in scope_permission_names(scope)
|
||||
149
tests/test_core/test_script_workingdirectory.py
Normal file
149
tests/test_core/test_script_workingdirectory.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""The optional per-script working directory.
|
||||
|
||||
A delivered script runs from the agent's staging directory. That is correct for
|
||||
a self-contained script and wrong for one that shells out to a corporate tool
|
||||
already installed on the endpoint by Intune and invokes it by relative path -
|
||||
sfld_autologon needs Autologon.exe beside it. This field pins the folder.
|
||||
|
||||
The validation here is a SHAPE check, not a privilege boundary: the body is
|
||||
already arbitrary SYSTEM PowerShell that could Set-Location anywhere. These
|
||||
tests pin that distinction so the field is not later mistaken for a security
|
||||
control, and pin the default so old scripts keep behaving exactly as before.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='scriptauthor',
|
||||
email='scriptauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'scriptauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_a_folder_stays_unpinned(client, headers):
|
||||
"""The default must remain "the agent decides", or every pre-existing
|
||||
script silently acquires a folder it was never authored for."""
|
||||
created = _create(client, headers, 'no-folder')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert not created.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_a_folder_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'autologon',
|
||||
workingdirectory=r'C:\Program Files\Sysinternals')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert (created.get_json()['data']['workingdirectory']
|
||||
== r'C:\Program Files\Sysinternals')
|
||||
|
||||
|
||||
def test_a_trailing_separator_is_trimmed(client, headers):
|
||||
created = _create(client, headers, 'trailing',
|
||||
workingdirectory='C:\\Tools\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_a_drive_root_keeps_its_separator(client, headers):
|
||||
r"""C:\ must not degrade to C:. On Windows "C:" is drive-RELATIVE and
|
||||
resolves to wherever the process last was on that drive, which is the exact
|
||||
opposite of pinning a folder - a silent wrong answer, not an error."""
|
||||
created = _create(client, headers, 'driveroot', workingdirectory='C:\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == 'C:\\'
|
||||
|
||||
|
||||
def test_forward_slashes_are_normalized(client, headers):
|
||||
created = _create(client, headers, 'slashes',
|
||||
workingdirectory='C:/Tools/Corporate')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools\Corporate'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [
|
||||
r'Tools\Corporate', # relative: relative to WHAT?
|
||||
r'\\fileserver\tools', # UNC: machine-account access to a file server
|
||||
r'C:\Tools\..\..\Windows', # traversal: obscures the real target
|
||||
])
|
||||
def test_a_malformed_folder_is_rejected_with_an_explanation(client, headers, bad):
|
||||
"""Rejected at the API, where the message can say why, rather than on the
|
||||
endpoint where the failure would name a missing tool instead."""
|
||||
created = _create(client, headers, f'bad-{abs(hash(bad))}',
|
||||
workingdirectory=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'workingdirectory' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_folder_can_be_cleared_back_to_the_default(client, headers):
|
||||
"""Reverting to staging must be reachable; otherwise a mistyped pin is
|
||||
permanent short of deleting and re-uploading the script."""
|
||||
scriptid = _create(client, headers, 'clearable',
|
||||
workingdirectory=r'C:\Tools').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'workingdirectory': ''})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert not updated.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even when empty, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered', filename='script-1.ps1',
|
||||
sha256='a' * 64, workingdirectory=r'C:\Tools')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_the_descriptor_sends_an_empty_string_when_unpinned(client, db, headers):
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='unpinned', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == ''
|
||||
@@ -113,7 +113,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
# and endpointmonitorimpacts.
|
||||
# 0018 adds endpointlocks: the admin quarantine refusing actions from non-admins
|
||||
# on one endpoint, a caller-authority axis separate from the policy hold.
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0018endpointlocks'
|
||||
# 0019 splits the 0007 bundle into one shipped Configuration Policy per monitor
|
||||
# and drops the shipped Business Policy, so sites compose their own.
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0019splitpolicies'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
@@ -206,6 +208,17 @@ def test_plugin_has_migration_chain(plugin):
|
||||
if isinstance(node.value, ast.Constant):
|
||||
down = node.value.value
|
||||
assert rev, f"{plugin}: {script.name} has no revision id"
|
||||
# alembic_version_<plugin>.version_num is VARCHAR(32). A longer id
|
||||
# passes every SQLite test and then fails the real deploy on strict
|
||||
# MySQL with "Data too long for column 'version_num'", after the
|
||||
# migration body has already run. Caught
|
||||
# endpoints0019splitmonitorpolicies (33 chars) during a container roll.
|
||||
# The CORE chain's alembic_version is VARCHAR(128), so core ids are not
|
||||
# bound by this and are not checked here.
|
||||
assert len(rev) <= 32, (
|
||||
f"{plugin}: {script.name} revision id {rev!r} is {len(rev)} chars; "
|
||||
'alembic_version.version_num holds 32'
|
||||
)
|
||||
assert found_down, f"{plugin}: {script.name} has no down_revision"
|
||||
revisions.add(rev)
|
||||
if down is None:
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""The shipped shopfloor app monitors ride a read-only Site Policy.
|
||||
|
||||
Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
|
||||
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
|
||||
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
|
||||
desired-state monitor action per shipped monitor) wrapped in a shipped
|
||||
BusinessPolicy "Shopfloor App Monitors".
|
||||
|
||||
Run the endpoints chain against a fresh engine, alongside the computers policy
|
||||
tables the bundle writes into, and assert the bundle exists, is source='shipped'
|
||||
(read-only), and attaches every shipped monitor as a desired/running:true action.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
|
||||
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
||||
|
||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
|
||||
def _load(path, prefix):
|
||||
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run_bundle_migrations(connection):
|
||||
"""Build the policy tables (computers 0004), the monitor catalog (endpoints
|
||||
0006) and the shipped bundle (endpoints 0007) on one engine, in order.
|
||||
|
||||
The core baseline creates every plugin's tables before per-plugin chains run,
|
||||
so on a real deploy the computers policy tables already exist when endpoints
|
||||
0007 runs. Here we stand up exactly the three migrations the bundle depends
|
||||
on - the earlier endpoints revisions carry FKs to core tables that a bare
|
||||
engine does not have, and are irrelevant to what 0007 seeds.
|
||||
"""
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
_load(_COMPUTERS_VERSIONS / '0004_policies.py',
|
||||
'computers_mig_').upgrade()
|
||||
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
|
||||
'endpoints_mig_0006_').upgrade()
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_0007_').upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
|
||||
def test_shipped_bundle_seeds_read_only_site_policy():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
config = connection.execute(text(
|
||||
'SELECT configurationpolicyid, source, scopejson '
|
||||
'FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).first()
|
||||
assert config is not None, 'shipped Configuration Policy was not seeded'
|
||||
config_id, config_source, scopejson = config
|
||||
assert config_source == 'shipped'
|
||||
assert json.loads(scopejson) == {'type': 'true'}
|
||||
|
||||
business = connection.execute(text(
|
||||
'SELECT businesspolicyid, source FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUSINESS_POLICY_NAME}).first()
|
||||
assert business is not None, 'shipped Business Policy was not seeded'
|
||||
business_id, business_source = business
|
||||
assert business_source == 'shipped'
|
||||
|
||||
item = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM businesspolicyitems '
|
||||
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
|
||||
assert item is not None and item[0] == config_id
|
||||
|
||||
|
||||
def test_shipped_bundle_attaches_every_shipped_monitor():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
monitor_ids = {row[0] for row in connection.execute(text(
|
||||
'SELECT endpointmonitorid FROM endpointmonitors '
|
||||
"WHERE source = 'shipped'"))}
|
||||
assert monitor_ids, 'no shipped monitors to attach'
|
||||
|
||||
config_id = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'), {'name': CONFIG_POLICY_NAME}).scalar()
|
||||
|
||||
actions = connection.execute(text(
|
||||
'SELECT action, mode, desiredstate FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': config_id}).all()
|
||||
|
||||
attached_ids = set()
|
||||
for action, mode, desiredstate in actions:
|
||||
assert action.startswith('monitor:')
|
||||
# Gate, not desired: the shipped bundle reports state and exposes
|
||||
# Start/Stop in the UI, but never auto-starts a process. A desired
|
||||
# running state is a deliberate per-monitor opt-in, not shipped.
|
||||
assert mode == 'gate'
|
||||
assert desiredstate is None
|
||||
attached_ids.add(int(action[len('monitor:'):]))
|
||||
|
||||
assert attached_ids == monitor_ids
|
||||
|
||||
|
||||
def test_shipped_bundle_is_idempotent():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
# Re-run only the shipped-bundle migration; it must not duplicate rows.
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_rerun_').upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
config_count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).scalar()
|
||||
business_count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUSINESS_POLICY_NAME}).scalar()
|
||||
assert config_count == 1
|
||||
assert business_count == 1
|
||||
224
tests/test_plugins/test_endpoints_split_monitor_policies.py
Normal file
224
tests/test_plugins/test_endpoints_split_monitor_policies.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""The shipped shopfloor monitors ride one Configuration Policy per monitor.
|
||||
|
||||
0007 seeded a single "Shopfloor App Monitors" bundle wrapped in a shipped Site
|
||||
Policy. 0019 replaced that with one policy per monitor and removed the shipped
|
||||
Site Policy, so sites compose their own.
|
||||
|
||||
Run the endpoints chain against a fresh engine, alongside the computers policy
|
||||
tables the migrations write into, and assert the post-0019 shape: a policy per
|
||||
shipped monitor, each carrying exactly one gate-mode monitor action with no PC
|
||||
type attribution, and no shipped Site Policy left behind.
|
||||
|
||||
The idempotency test is the load-bearing one. Nothing reconciles shipped rows,
|
||||
so insert-if-absent is the only thing protecting a site's edits from an upgrade
|
||||
that re-runs a seeding migration - see POLICY_SOURCE_SHIPPED in
|
||||
plugins/computers/models/policy.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
|
||||
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
SPLIT_MIGRATION = '0019_split_shipped_monitor_policies.py'
|
||||
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _load(path, prefix):
|
||||
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run(connection, *loaded):
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
for module in loaded:
|
||||
module.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _run_bundle_migrations(connection):
|
||||
"""Build the policy tables, monitor catalog, bundle and split, in order.
|
||||
|
||||
The core baseline creates every plugin's tables before per-plugin chains
|
||||
run, so on a real deploy the computers policy tables already exist. Here we
|
||||
stand up exactly the migrations the bundle depends on - the earlier
|
||||
endpoints revisions carry FKs to core tables a bare engine does not have.
|
||||
"""
|
||||
_run(
|
||||
connection,
|
||||
_load(_COMPUTERS_VERSIONS / '0004_policies.py', 'computers_mig_'),
|
||||
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
|
||||
'endpoints_mig_0006_'),
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_0007_'),
|
||||
_load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION, 'endpoints_mig_0019_'),
|
||||
)
|
||||
|
||||
|
||||
def _monitor_id(connection, kind, displayname):
|
||||
return connection.execute(text(
|
||||
'SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def test_split_seeds_one_policy_per_shipped_monitor():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
row = connection.execute(text(
|
||||
'SELECT configurationpolicyid, source, scopejson '
|
||||
'FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': name}).first()
|
||||
assert row is not None, f'{name} was not seeded'
|
||||
policyid, source, scopejson = row
|
||||
assert source == 'shipped'
|
||||
# No PC type attribution: the policy applies wherever a site
|
||||
# composes it, and carries no opinion about which types match.
|
||||
assert json.loads(scopejson) == {'type': 'true'}
|
||||
|
||||
actions = connection.execute(text(
|
||||
'SELECT action, mode, desiredstate '
|
||||
'FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid}).all()
|
||||
assert len(actions) == 1, f'{name} should carry exactly one action'
|
||||
action, mode, desiredstate = actions[0]
|
||||
# Gate, not desired: a monitor reports state and exposes Start/Stop
|
||||
# but never auto-starts a process. A desired running state is a
|
||||
# deliberate per-monitor opt-in, not something the product ships on.
|
||||
assert mode == 'gate'
|
||||
assert desiredstate is None
|
||||
expected = _monitor_id(connection, kind, displayname)
|
||||
assert action == f'monitor:{expected}'
|
||||
|
||||
|
||||
def test_split_removes_the_shipped_site_policy():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
business = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert business == 0, 'the shipped Site Policy must not survive 0019'
|
||||
|
||||
config = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert config == 0, 'the bundled Configuration Policy must be retired'
|
||||
|
||||
items = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicyitems')).scalar()
|
||||
assert items == 0
|
||||
|
||||
|
||||
def test_no_shipped_business_policy_remains():
|
||||
"""Sites build their own composition; the product ships none."""
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
shipped = connection.execute(text(
|
||||
"SELECT COUNT(*) FROM businesspolicies WHERE source = 'shipped'"
|
||||
)).scalar()
|
||||
assert shipped == 0
|
||||
|
||||
|
||||
def test_split_is_idempotent_and_preserves_site_edits():
|
||||
"""A re-run must not duplicate rows nor stomp an edit.
|
||||
|
||||
Nothing reconciles shipped rows, so this is the whole protection: the
|
||||
migration returns early when the policy name is already present. If that
|
||||
ever becomes a reconciling seeder, this test is what fails.
|
||||
"""
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
edited = _policy_name('process', 'eDNC')
|
||||
connection.execute(text(
|
||||
'UPDATE configurationpolicies SET description = :description '
|
||||
'WHERE name = :name'),
|
||||
{'description': 'site edited', 'name': edited})
|
||||
connection.commit()
|
||||
|
||||
_run(connection, _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
|
||||
'endpoints_mig_rerun_'))
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': _policy_name(kind, displayname)}).scalar()
|
||||
assert count == 1
|
||||
|
||||
description = connection.execute(text(
|
||||
'SELECT description FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': edited}).scalar()
|
||||
assert description == 'site edited', 'a re-run stomped a site edit'
|
||||
|
||||
|
||||
def test_downgrade_restores_the_bundle():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
split = _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
|
||||
'endpoints_mig_down_')
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
split.downgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
config_id = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
assert config_id is not None, 'downgrade did not restore the bundle'
|
||||
|
||||
business_id = connection.execute(text(
|
||||
'SELECT businesspolicyid FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert business_id is not None
|
||||
|
||||
item = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM businesspolicyitems '
|
||||
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
|
||||
assert item is not None and item[0] == config_id
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': _policy_name(kind, displayname)}).scalar()
|
||||
assert count == 0, 'downgrade left the split policies behind'
|
||||
Reference in New Issue
Block a user