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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
252
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
252
plugins/endpoints/frontend/views/SystemScriptsSettings.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<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>
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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/)
|
||||
})
|
||||
})
|
||||
@@ -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,8 +6,23 @@ 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
|
||||
@@ -32,6 +47,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 +62,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 +75,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:
|
||||
@@ -74,7 +94,7 @@ def _validate_runcontext(value):
|
||||
|
||||
|
||||
@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 {}
|
||||
@@ -103,7 +123,7 @@ def create_script():
|
||||
|
||||
|
||||
@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:
|
||||
@@ -135,7 +155,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 +169,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 +216,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)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user