Files
shopdb-flask/plugins/endpoints/frontend/views/SystemScriptsSettings.vue
benjamin.baker fd18099199 Gate the script library on the grant that pushes scripts
The script library was gated on applications.*, so anyone holding the
software-delivery grant could rewrite the bytes of a script that a separately
trusted operator later pushed to a bay. The push was gated on
endpoints.runscript; the CONTENT of what got pushed was not. An installer is a
vendor artefact, but a script here is arbitrary PowerShell this site wrote that
the agent runs as SYSTEM, so the write and the push must require the same
trust.

All six script routes now require endpoints.runscript, including the body
download - reading the bytes is not a lesser act than listing them. Core names
the permission as a string rather than importing it from the endpoints plugin
so the module stays importable with the plugin absent.

This tightens access and there is deliberately no backfill: the operator scope
reaches applications.create/edit, so operator-scope holders lose the library.
Granting them endpoints.runscript on upgrade would undo the change and hand
every operator remote SYSTEM code execution, since the same grant gates the
push. A test pins that no prebuilt scope confers it.

Adds the System Scripts settings page and card. The route requires the
permission rather than admin, because the grant is meant for non-admin
operators and the sibling admin gate would lock them out of the library the
grant exists for. The settings icon map gains terminal and key; the agent-key
card had been asking for key since it shipped and silently drawing a puzzle
piece.

The library had no test coverage before this.
2026-09-09 06:37:02 -04:00

253 lines
7.7 KiB
Vue

<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>
<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>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>
<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="6" 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>
<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: '',
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 || '',
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,
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, #b8860b);
border-left-width: 4px;
border-radius: 4px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(184, 134, 11, 0.08);
}
</style>