Add Corporate Tools tab, Dell Command actions, and user-context refresh

Corporate Tools tab on PC Details, gated by endpoints.deploy and liveness.
Starts already-registered scheduled tasks; the agent's ScheduledTaskPolicy
allowlist remains the authority on what may actually run.

Dell Command | Update split into two actions (Scan, Apply Updates) because
they have very different consequences. Apply Updates carries a persistent
breakglass warning in the card naming Tanium as the primary update utility,
rendered between the row title and description so it is visible at all times
rather than only in the confirm dialog. Destructive actions keep the typed
RUN confirmation.

Fix scheduledtask_run being stripped by Configuration Policy: the
intersection in ALLOWED_ACTIONS_FOR dropped it on any endpoint whose
effective policy was non-empty, so every Corporate Tools click failed with
"not permitted by policy" regardless of the caller's role. Corporate tools
are role-gated only, via POLICY_EXEMPT_ACTIONS; policy keeps authority over
monitors and service/process primitives.

Fix blank PC Details page: watch(detailtabs) evaluates its source during
setup and read canDeploy before its const declaration, throwing a TDZ
ReferenceError that aborted setup with no API request made.

Mapped-drive refresh moves to the tray. SYSTEM cannot probe user-scoped
drives, so the service requests a re-probe over a named event and waits for
the spool collectedat stamp to advance before collecting. The probe task is
registered as a ShopFloor user principal; the Users-group principal remains
only as a degraded fallback since a group member cannot start it.
This commit is contained in:
benjamin.baker
2026-09-06 05:22:05 -04:00
parent f832c376f1
commit cd705563d0
25 changed files with 1756 additions and 58 deletions

View File

@@ -423,6 +423,42 @@
<div v-show="activegroup === 'activity'" class="detail-full">
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
</div>
<!-- Corporate Tools: fixed triggers for scheduled tasks already
registered on the endpoint. The agent only STARTS a task it already
holds and that clears its approved-task allowlist - it never defines
one - so the endpoint stays the authority on what actually runs. -->
<div v-show="activegroup === 'corptools'" class="detail-full">
<div class="card">
<h3>Corporate Tools</h3>
<p class="corptools-intro">
Starts a scheduled task that is already registered on this computer.
Each task runs in the security context its own definition specifies.
</p>
<!-- Persistent by design: not a dismissible toast and not shown only
on click. These tasks run real corporate tooling as SYSTEM on a
machine someone may be sitting at, and the warning has to still
be on screen at the moment an operator decides to press Run. -->
<p class="corptools-warning">
These actions take effect immediately on the live computer and
cannot be undone from here. A Dell update run can reboot the machine
and may interrupt the person using it.
</p>
<div class="corptools-list">
<div v-for="tool in corporateTools" :key="tool.task" class="corptools-row">
<div class="corptools-text">
<div class="corptools-name">{{ tool.label }}</div>
<p v-if="tool.warning" class="corptools-rowwarning">{{ tool.warning }}</p>
<div class="corptools-desc">{{ tool.description }}</div>
</div>
<button
class="btn btn-warning btn-sm"
:disabled="firingTask === tool.task"
@click="confirmRunTask(tool)"
>{{ firingTask === tool.task ? 'Starting...' : 'Run' }}</button>
</div>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
@@ -575,6 +611,13 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
// Gates the Corporate Tools tab. endpoints.deploy currently bundles
// scheduledtask_run with run_script/update_app - see the note beside the
// permission declaration in plugins/endpoints/plugin.py if that gets split.
// Declared ahead of detailtabs because the watch below evaluates that computed
// during setup, which reads canDeploy before this line would otherwise run.
const canDeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
@@ -595,6 +638,13 @@ const detailtabs = computed(() => {
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
}
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
// Corporate Tools is permission-gated rather than data-gated: it is a set of
// fixed actions, not a rendering of collected telemetry, so there is no
// populatedGroup to key off. Hidden without endpoints.deploy because every
// button here is an elevated command; the server re-checks regardless.
if (canDeploy.value && liveness.value) {
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
}
return tabs
})
@@ -805,6 +855,93 @@ async function confirmRestart() {
}
}
// Fixed set, deliberately not user-editable from this page: these are the task
// paths the agent ships as its approved list (ScheduledTaskPolicy). Adding one
// here without adding it to the agent's allowlist gets a clean rejection from
// the endpoint rather than an execution, which is the intended failure mode.
const corporateTools = [
{
task: '\\ReportIP',
label: 'Report IP',
description: 'Re-runs the corporate IP reporting agent on this computer.',
},
{
task: '\\SFLD-ApplyDSCConfig',
label: 'Apply DSC Configuration',
description: 'Re-applies the shop floor DSC baseline. Runs as SYSTEM and '
+ 'may change system configuration.',
},
{
task: '\\SFLD - Consume Credentials',
label: 'Consume Credentials',
description: 'Runs the credential consumption task in the logged-on '
+ 'user\'s session. Requires a user to be signed in.',
},
// Dell Command | Update, split into two entries because they are two
// utilities with very different consequences: the scan only reports, the
// update installs and may reboot. Keeping them as separate buttons means the
// destructive one is never a field edit away from the safe one.
{
task: '\\ShopDB-DellCommandScan',
label: 'Dell Command: Scan',
description: 'Scans for available Dell driver and firmware updates. '
+ 'Reports only - installs nothing and does not reboot.',
},
{
task: '\\ShopDB-DellCommandUpdate',
label: 'Dell Command: Apply Updates',
// Persistent breakglass warning. Tanium is the primary update utility for
// this fleet; this tool exists only for when Tanium has failed. The text is
// rendered in the card at all times (not behind a hover or the confirm
// dialog) so the operator cannot reach Run without having seen it.
warning: 'These updates are meant to be executed by the primary utility - '
+ 'Tanium. This tool should only be used in the event of a failed '
+ 'update or a problem with Tanium. This is a breakglass update '
+ 'utility to account for critical failures in the primary '
+ 'management solution.',
description: 'Installs available Dell driver and firmware updates. Runs as '
+ 'SYSTEM and MAY REBOOT this computer when it finishes.',
destructive: true,
},
]
const firingTask = ref('')
// 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.deploy regardless.
//
// Tools marked destructive get a TYPED confirmation rather than an OK button,
// for the same reason Restart has its own gate: applying firmware updates can
// reboot a machine that may be mid-cycle on the shop floor, and a misclick
// should not be able to do that.
async function confirmRunTask(tool) {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (tool.destructive) {
const typed = prompt(`${tool.label} on ${label}\n\n`
+ `${tool.description}\n\n`
+ 'Type RUN to confirm.')
if (typed !== 'RUN') return
} else if (!confirm(`Run "${tool.label}" on ${label}?\n\n`
+ `${tool.description}\n\n`
+ 'The task starts immediately on the endpoint.')) {
return
}
firingTask.value = tool.task
try {
await endpointsApi.enqueueCommand(
computer.value.assetid, 'scheduledtask_run', tool.task)
// "Started", not "finished": the agent reports success as soon as Task
// Scheduler accepts the task, so this never means the work is done. Any
// result the task produces comes back through telemetry, not this command.
toast.success(`${tool.label} started on ${label}`)
} catch (error) {
toast.error(apiError(error, `Failed to queue ${tool.label}`))
} finally {
firingTask.value = ''
}
}
// 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.
@@ -995,6 +1132,60 @@ async function confirmRetire() {
}
.triage-chip.ok .triage-dot { background: var(--success); }
.triage-chip.warning .triage-dot { background: var(--warning); }
/* Corporate Tools: a plain action list, not a data table - each row is a
trigger with the consequence stated next to it, so the operator reads what
it does before the confirm gate rather than after. */
.corptools-intro {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Warning weight without alert-banner styling: it is permanent, so a loud
dismissible-looking banner would be tuned out within a day. The left rule and
warning tint keep it legible as a standing caution. */
.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);
font-size: 0.875rem;
}
.corptools-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.corptools-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.corptools-name {
font-weight: 600;
}
.corptools-desc {
color: var(--text-secondary);
font-size: 0.8125rem;
margin-top: 0.125rem;
}
/* Row-level breakglass notice. Carries the same left-rule treatment as the
card-level warning so it reads as a caution and not as body copy, but sits
inside the row it applies to. */
.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);
font-size: 0.8125rem;
font-weight: 500;
}
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }

View File

@@ -494,6 +494,60 @@ def _drift_for_state(state):
return parsed if isinstance(parsed, list) else []
def _dell_updates_summary(state):
"""Parse the cached Dell Command | Update summary off an EndpointState.
dellupdatesjson is written on check-in from the shopdb-dellupdates agent
utility, which reads the log the \\ShopDB-DellCommandScan task produces.
Null means the endpoint has never reported a scan. Tolerant of a malformed
blob so a bad cache never breaks the panel.
"""
raw = getattr(state, 'dellupdatesjson', None) if state else None
if not raw:
return None
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
def _dell_updates_value(state):
"""Render the informational 'Pending Dell updates' status value.
Informational only - this row never claims an endpoint needs attention,
because a pending optional driver is not a support problem. Three states are
kept distinct on purpose, and all three are VISIBLE:
never scanned -> 'Never scanned'. Deliberately not 'No': those are
different facts, and claiming 'No' would tell an operator
the machine is current when nobody has ever checked. It
is also not hidden - a missing row reads as a broken
panel, so the unknown state says so out loud.
scanned clean -> 'No'.
pending -> 'Yes (n)', with the count because one optional update and
fourteen urgent ones warrant different urgency.
"""
summary = _dell_updates_summary(state)
if not summary:
return 'Never scanned'
status = summary.get('status')
if status == 'error':
# The scan log exists but could not be read. A different problem from
# never having scanned, and worth saying so rather than implying the
# endpoint was never checked.
return 'Unknown (scan unreadable)'
if status != 'ok':
return 'Never scanned'
try:
count = int(summary.get('count') or 0)
except (TypeError, ValueError):
return 'Unknown (scan unreadable)'
if count <= 0:
return 'No'
return 'Yes ({})'.format(count)
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
@@ -530,6 +584,11 @@ def panel_status(asset_id: int):
]
fields = [f for f in fields if f['value'] not in (None, '')]
# Informational only, and appended after the null-filter above because it
# always renders - including an explicit "Never scanned", which a hidden row
# would leave looking like a broken panel.
fields.append(field('Pending Dell updates', _dell_updates_value(state)))
# Warn-only reconciler summary: monitors site policy expects to be running
# that the last check-in did not report as running. Surfaced as a status
# field so an operator sees the drift on the panel; nothing is auto-started.

View File

@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
# a site can narrow or widen the set without an agent rebuild. Anything not
# matched fails closed.
'approvedtaskpaths': [],
}
_README = """ShopDB endpoint agent (site build)
@@ -224,6 +230,11 @@ param(
# reliable way to collect them; see the registration block below.
[string] $UserContextTaskName = "ShopDB user-context probe",
[int] $UserContextTaskMinutes = 30,
# The account the probe task runs as. Must match the account whose profile
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
# the probe writes session data the service never looks at.
[string] $UserContextTaskUser = "ShopFloor",
[switch] $NoUserContextTask
)
@@ -385,14 +396,23 @@ if (-not $NoTray) {{
}}
}}
# 2b. Register the user-context probe as a scheduled task owned by the Users
# GROUP. This is what makes mapped drives, user listeners and idle time
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
# USER. This is what makes mapped drives, user listeners and idle time
# collectable: it is the only mechanism that starts a process in the operator's
# own session with the operator's own token.
#
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
# token is precisely what sees the operator's mapped drives. GroupId rather
# than UserId so the task runs as WHOEVER signs in, each in their own session.
# token is precisely what sees the operator's mapped drives.
#
# UserId, NOT GroupId. A group-principal task is registered against the group
# rather than against any one account, and a member of that group cannot invoke
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
# denied, so the task only ever ran on its own trigger. That is exactly the bug
# where "Refresh Status" appeared to do nothing. Registering to the specific
# account makes the task startable by that account, which is what both the tray
# and any manual retry depend on. The group form is kept only as a fallback for
# a host where the account cannot be resolved - it still collects on a timer,
# it just cannot be kicked on demand.
$taskState = "skipped"
if (-not $NoUserContextTask) {{
Write-Host " registering user-context probe task ..."
@@ -410,15 +430,26 @@ if (-not $NoUserContextTask) {{
}}
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# BUILTIN\\Users by SID first, then by name: the SID is locale
# independent (on non-English Windows the group is not called "Users"),
# but a hardened host can still reject it.
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
# Try the machine-qualified account first, then the bare name. Interactive
# LogonType so the task joins the existing interactive session instead of
# creating a separate one with its own (empty) drive table.
$taskCandidates = @(
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
@{{ Kind = "user"; Id = $UserContextTaskUser }},
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
)
foreach ($candidate in $taskCandidates) {{
try {{
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
if ($candidate.Kind -eq "user") {{
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
}}
else {{
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
}}
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
$taskRegistered = $true
$taskDetail = $groupId
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
break
}}
catch {{ $taskDetail = $_.Exception.Message }}
@@ -440,12 +471,17 @@ if (-not $NoUserContextTask) {{
$taskState = "UNVERIFIED:elevated"
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
}}
elseif (-not $verified.Principal.GroupId) {{
$taskState = "UNVERIFIED:no group principal"
Write-Warning " user-context task has no group principal; it would run as one specific account."
elseif ($verified.Principal.GroupId) {{
# Registered, will collect on its triggers, but cannot be started on
# demand by the operator - so "Refresh Status" will fall back to the
# timer. Surfaced rather than reported as ok, because the symptom
# (stale session data that never responds to a refresh) is otherwise
# indistinguishable from a broken probe.
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
}}
else {{
$taskState = "ok:$($verified.Principal.GroupId)/$($verified.Principal.RunLevel)"
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
Write-Host " user-context task registered and verified: $taskState"
}}

View File

@@ -83,12 +83,30 @@ COMMAND_ALLOWLIST = {
'Read-only diagnostic; the standard report carries only '
'listening ports because established connections churn.',
},
'scheduledtask_run': {
'label': 'Run scheduled task',
'description': 'Start an already-registered Windows scheduled task on '
'the endpoint by its task path (for example '
'\\ReportIP). The agent does not create, modify, or '
'define tasks - it only starts one that already exists, '
'and only when the path clears the agent-side approved '
'task allowlist. The task runs in whatever context its '
'own registered definition specifies, not the agent\'s.',
'targetkind': 'scheduledtask',
},
'run_script': {
'label': 'Run script',
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
'fetches the script body by digest using its collector '
'credential and runs it in the script\'s configured '
'context (system or the logged-on user).',
'context (system or the logged-on user). CAUTION: a '
'script sent this way runs as SYSTEM unless its '
'descriptor pins the user context, so this is arbitrary '
'code execution on the endpoint by design. Grant '
'endpoints.deploy only to the most trusted operators, '
'and gate the UI behind an explicit confirm the way '
'system_restart is gated (see confirmRestart in '
'PCDetail.vue).',
'targetkind': 'script',
},
'update_app': {
@@ -102,6 +120,35 @@ COMMAND_ALLOWLIST = {
}
# Dell Command | Update: TWO utilities, deliberately two separate actions.
# A scan reports what is available and changes nothing; an update installs and
# can reboot. Collapsing them into one parameterized action would make the
# destructive one reachable by editing a field, so they stay distinct and the
# UI can gate them differently.
#
# Both are driven through scheduledtask_run against ShopDB-owned task
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
# output path, which is why no new agent code is needed to run them.
#
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
# the default; keeping ShopDB's output separate is the only thing that makes
# "did ShopDB ask for this scan, or did corporate" answerable when the results
# are collected. The path lives in the task definition and is generated
# server-side, so it can change without touching the agent.
#
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
# means "scan started", never "scan finished" - the command result cannot carry
# findings. Reporting available updates back into ShopDB needs a collector that
# reads the report from the path above and folds it into the telemetry payload.
# That is a separate change from this one; do not infer from a green command
# result that the data path exists.
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
# placement note so the next person adds it here rather than inventing a new
# transport. When it lands it will re-establish a Disconnected mapping surfaced
@@ -126,8 +173,23 @@ COMMAND_ALLOWLIST = {
# Actions that demand a permission stricter than the base endpoints.command.
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
# NOTE FOR THE ROLES/PERMISSIONS WORK: scheduledtask_run is deliberately mapped
# onto the EXISTING endpoints.deploy grant rather than introducing a new one, so
# the wire is pre-built and needs no migration. Be aware this WIDENS what
# endpoints.deploy means: it previously covered run_script + update_app, and now
# also covers starting approved scheduled tasks. If that bundle is too coarse,
# the clean split is a separate endpoints.corptools permission mapped here - no
# other code needs to change, because the enqueue route reads this table.
#
# Suggested operator-facing wording for the combined grant:
# "Caution: Operator can run scheduled tasks, update applications, and affect
# system-level changes to the endpoint."
# That wording is accurate for scheduledtask_run and update_app. It UNDERSTATES
# run_script, which is arbitrary SYSTEM code execution - if run_script stays in
# this bundle, say so plainly in the role description.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'scheduledtask_run': 'endpoints.deploy',
'run_script': 'endpoints.deploy',
'update_app': 'endpoints.deploy',
}
@@ -242,6 +304,24 @@ def monitor_id_from_action(action):
return None
# Actions gated by ROLE ONLY, never by per-endpoint Configuration Policy.
#
# These are the Corporate Tools actions. They start already-registered,
# agent-allowlisted tasks and are not a per-machine configuration concern, so a
# policy that never mentions them must not remove them. Policy exists to shape
# what a given PC TYPE exposes (which monitors, which service primitives); it is
# not the authority on whether an admin may run a corporate tool.
#
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
# silently drops these actions on any endpoint whose effective policy is
# non-empty, producing "Action not permitted by policy for this endpoint" even
# for a caller holding endpoints.deploy. The real gate for these stays
# ELEVATED_ACTION_PERMISSIONS (role), enforced in the enqueue route.
POLICY_EXEMPT_ACTIONS = frozenset({
'scheduledtask_run',
})
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
@@ -250,8 +330,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
result is narrowed to the actions the endpoint's effective policy permits -
this is the allowlist-gating half of the policy design. With the feature off
(or no assetid), it returns the full base allowlist, so nothing changes until
a site opts in. Policy resolution is imported lazily to keep this module free
of a hard dependency on the computers plugin.
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away;
they are gated by role alone. Policy resolution is imported lazily to keep
this module free of a hard dependency on the computers plugin.
"""
base = set(COMMAND_ALLOWLIST.keys())
if assetid is None:
@@ -274,6 +355,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
# "not permitted by policy". Expand each monitor:<id> to its kind's control
# actions before intersecting.
allowed = allowed | _monitor_control_actions(allowed)
# Role-gated actions are added back unconditionally: policy narrows the
# per-endpoint surface, but it does not own the corporate tools.
allowed = allowed | POLICY_EXEMPT_ACTIONS
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed

View File

@@ -0,0 +1,51 @@
"""Add dellupdatesjson to endpointstates.
Stores the pending Dell Command | Update summary reported by the
shopdb-dellupdates agent utility. MEDIUMTEXT for consistency with the other
snapshot columns on this table.
Nullable with no backfill. Null means the endpoint has never reported a Dell
scan result, which is deliberately distinct from a stored payload whose status
is 'ok' with an empty list (scanned, nothing pending). The status card renders
those two states differently, so defaulting this to an empty summary would
assert every pre-existing endpoint had been scanned and found clean.
Idempotent: the column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0016dellupdates'
down_revision = 'endpoints0015systemtelemetry'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
COLUMN_NAME = 'dellupdatesjson'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column(TABLE_NAME, COLUMN_NAME):
op.add_column(
TABLE_NAME,
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, COLUMN_NAME):
op.drop_column(TABLE_NAME, COLUMN_NAME)

View File

@@ -129,6 +129,16 @@ class EndpointState(db.Model):
# already accumulating when someone is ready to interpret it.
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
# the count drives the status card; the short list is stored so the panel
# can show what is actually pending without a second round trip.
#
# A null column and a stored payload with status 'unknown' both mean "never
# scanned", which stays distinct from a scan that found nothing pending. The
# status card relies on that distinction - see _dell_updates_field.
dellupdatesjson = db.Column(db.Text(length=16777215), nullable=True)
# Warn-only reconciler output, refreshed on every agent check-in in the same
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
# the endpoint is expected to be running but the latest snapshot does not

View File

@@ -54,7 +54,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
'scheduledtasks', 'applications', 'events',
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext', 'diskhealth',
'rebootreasons', 'bootinfo', 'defender',
'rebootreasons', 'bootinfo', 'defender', 'dellupdates',
})
@@ -182,12 +182,32 @@ class EndpointsPlugin(BasePlugin):
# remote reboot is a strictly heavier grant than a routine toggle.
('endpoints.restart',
'Restart endpoint computers (destructive)', 'endpoints'),
# Elevated grant for software delivery (run_script, update_app).
# Elevated grant for software delivery (run_script, update_app) and
# for starting approved scheduled tasks (scheduledtask_run).
# Required IN ADDITION to endpoints.command by the enqueue route, so
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
#
# NOTE FOR THE ROLES WORK - this wire is pre-built for you.
# scheduledtask_run was deliberately mapped onto this EXISTING grant
# rather than minting a new permission, so nothing needs migrating.
# Be aware that WIDENS what endpoints.deploy means: it used to cover
# run_script + update_app, and now also covers starting any task on
# the agent's approved-task allowlist. Splitting it out is a one-line
# change (add endpoints.corptools here and remap it in
# commands.ELEVATED_ACTION_PERMISSIONS) - the enqueue route reads
# that table, so no other code changes.
#
# If you do split it, note the three are NOT equal risk:
# scheduledtask_run only starts something already registered on the
# endpoint and is allowlisted agent-side; update_app runs a pinned
# installer; run_script is arbitrary code that runs as SYSTEM unless
# its descriptor pins the user context. run_script is the reason this
# grant's description has to stay blunt.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
'Run staged scripts, start approved scheduled tasks, and push '
'application installers to endpoints (scripts run as SYSTEM; '
'grant only to fully trusted operators)', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
@@ -1113,6 +1133,13 @@ class EndpointsPlugin(BasePlugin):
if 'defender' in payload:
state.defenderjson = _as_json(payload.get('defender'))
# Presence-guarded like the probe output below: the agent omits the key
# entirely when the collector utility is not installed, and overwriting
# to null there would discard a still-valid scan result from before the
# utility was removed.
if 'dellupdates' in payload:
state.dellupdatesjson = _as_json(payload.get('dellupdates'))
# Session-scoped probe output. The agent repeats its last result between
# probe runs and omits the key entirely when nobody is signed in, so the
# presence guard preserves the last known mapped drives across an