main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
924 lines
39 KiB
Python
924 lines
39 KiB
Python
"""Admin break-glass over policy gating, and the holds that make it stick.
|
|
|
|
Policy gating is right almost always and wrong at 3am. These tests pin the
|
|
override's boundaries: it must be explicit, admin-only, reasoned, audited, and
|
|
it must NOT become a way to acquire an elevated permission the caller lacks.
|
|
|
|
The hold half exists because an override on a desired-mode action is a fight
|
|
with the reconciler - without suppression the next check-in re-enqueues the
|
|
opposite command and the override looks broken.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from shopdb.core.models import Setting
|
|
|
|
|
|
def _enable_policies(db):
|
|
if not Setting.query.filter_by(key='computers_configpolicies_enabled').first():
|
|
db.session.add(Setting(key='computers_configpolicies_enabled',
|
|
value='true'))
|
|
db.session.commit()
|
|
|
|
|
|
def _endpoint(db, hostname='holdbox'):
|
|
"""An Asset + Computer pair, the minimum a policy can resolve against."""
|
|
from shopdb.api import Asset, AssetType
|
|
from plugins.computers.models import Computer
|
|
|
|
assettype = AssetType.query.filter_by(assettype='computer').first()
|
|
if assettype is None:
|
|
assettype = AssetType(assettype='computer', pluginname='computers',
|
|
tablename='computers')
|
|
db.session.add(assettype)
|
|
db.session.flush()
|
|
|
|
asset = Asset(assetnumber=hostname, name=hostname,
|
|
assettypeid=assettype.assettypeid, isactive=True)
|
|
db.session.add(asset)
|
|
db.session.flush()
|
|
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
|
# The enqueue route 404s without a reported state, so the endpoint has to
|
|
# look like it has checked in at least once.
|
|
from plugins.endpoints.models import EndpointState
|
|
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
|
|
db.session.commit()
|
|
return asset.assetid
|
|
|
|
|
|
def _login(client, username, password='testpass'):
|
|
response = client.post('/api/auth/login',
|
|
json={'username': username, 'password': password})
|
|
token = response.get_json()['data']['access_token']
|
|
return {'Authorization': f'Bearer {token}'}
|
|
|
|
|
|
def _user(db, client, username, permnames, admin=False):
|
|
from shopdb.core.models import User, Role, Permission
|
|
|
|
Permission.seed()
|
|
Permission.seed_entries([
|
|
('endpoints.restart', 'Remote restart', 'endpoints'),
|
|
])
|
|
db.session.flush()
|
|
role = Role(rolename='admin' if admin else username + 'role')
|
|
existing = Role.query.filter_by(rolename=role.rolename).first()
|
|
if existing is not None:
|
|
role = existing
|
|
else:
|
|
db.session.add(role)
|
|
db.session.flush()
|
|
role.permissions = Permission.query.filter(
|
|
Permission.name.in_(permnames)).all()
|
|
user = User(username=username, email=username + '@test.local',
|
|
passwordhash=generate_password_hash('testpass'))
|
|
user.roles.append(role)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return _login(client, username)
|
|
|
|
|
|
def _deny_everything(db, assetid):
|
|
"""A policy that matches this endpoint but permits only an unrelated action,
|
|
so the action under test is denied by gating."""
|
|
from plugins.computers.models import (ConfigurationPolicy,
|
|
ConfigurationPolicyAction,
|
|
BusinessPolicy, BusinessPolicyItem)
|
|
policy = ConfigurationPolicy(name='Locked down', scopejson=None)
|
|
db.session.add(policy)
|
|
db.session.flush()
|
|
db.session.add(ConfigurationPolicyAction(
|
|
configurationpolicyid=policy.configurationpolicyid,
|
|
action='mtconnect_status'))
|
|
business = BusinessPolicy(name='Fleet')
|
|
db.session.add(business)
|
|
db.session.flush()
|
|
db.session.add(BusinessPolicyItem(
|
|
businesspolicyid=business.businesspolicyid,
|
|
configurationpolicyid=policy.configurationpolicyid))
|
|
db.session.commit()
|
|
|
|
|
|
def _url(assetid):
|
|
return f'/api/endpoints/{assetid}/commands'
|
|
|
|
|
|
def test_policy_denial_still_applies_without_an_override(client, db):
|
|
"""The default path must be unchanged: no flag, no bypass."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'plainadmin',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler'})
|
|
assert response.status_code == 400, response.get_json()
|
|
assert 'not permitted by policy' in \
|
|
response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_a_non_admin_cannot_override_a_policy_denial(client, db):
|
|
"""endpoints.command is enough to queue work, never enough to break glass."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'operator',
|
|
['endpoints.view', 'endpoints.command'])
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler',
|
|
'policyoverride': True,
|
|
'overridereason': 'trying it on'})
|
|
assert response.status_code == 403, response.get_json()
|
|
assert 'admin' in response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_an_override_without_a_reason_is_refused(client, db):
|
|
"""An unexplained break-glass is an unreviewable one."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'reasonless',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler',
|
|
'policyoverride': True})
|
|
assert response.status_code == 400, response.get_json()
|
|
assert 'overridereason' in response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_an_admin_override_is_accepted_and_audited(client, db):
|
|
"""The whole point, plus the audit trail that justifies allowing it."""
|
|
from shopdb.api import AuditLog
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'breakglass',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler',
|
|
'policyoverride': True,
|
|
'overridereason': 'line down, need status'})
|
|
assert response.status_code in (200, 201), response.get_json()
|
|
|
|
entry = AuditLog.query.filter_by(action='policyoverridden').first()
|
|
assert entry is not None, 'break-glass must leave an audit record'
|
|
|
|
|
|
def test_a_refused_override_leaves_no_hold_and_no_audit_row(client, db):
|
|
"""Validation after the override branch must run before anything is written.
|
|
|
|
A targetless service action, and a second command while one is in flight,
|
|
are both refused. Neither may leave a policyoverridden audit row or a
|
|
PolicyActionHold behind, or the record claims a break-glass that never ran.
|
|
"""
|
|
from shopdb.api import AuditLog
|
|
from plugins.computers.models import PolicyActionHold
|
|
from plugins.endpoints.models import EndpointCommand
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db, 'refusedbox')
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'refusedadmin',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
missing_target = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'policyoverride': True,
|
|
'overridereason': 'no target given'})
|
|
assert missing_target.status_code == 400, missing_target.get_json()
|
|
|
|
db.session.add(EndpointCommand(assetid=assetid, action='collect_now',
|
|
status=EndpointCommand.STATUS_PENDING))
|
|
db.session.commit()
|
|
in_flight = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler',
|
|
'policyoverride': True,
|
|
'overridereason': 'queue is busy'})
|
|
assert in_flight.status_code == 409, in_flight.get_json()
|
|
|
|
db.session.expire_all()
|
|
assert AuditLog.query.filter_by(action='policyoverridden').count() == 0
|
|
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
|
|
|
|
|
|
def test_override_is_checked_after_the_elevated_permission_gate(client, db):
|
|
"""The override widens WHERE an action may run, never WHICH actions the
|
|
caller may use.
|
|
|
|
A human admin holds every permission via the admin bypass, so this is moot
|
|
for people and cannot be shown with an admin fixture. It matters for TOKENS:
|
|
authz suspends the admin bypass for a scoped personal token, so a token
|
|
scoped to endpoints.command is refused at the elevated gate and never
|
|
reaches the override branch at all - even though its owner is an admin, and
|
|
even though it asks for the override explicitly.
|
|
"""
|
|
from shopdb.core.models import Permission
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db, 'tokenbox')
|
|
_deny_everything(db, assetid)
|
|
Permission.seed_entries([
|
|
('apitokens.create', 'Mint tokens', 'core'),
|
|
])
|
|
db.session.commit()
|
|
ownerheaders = _user(db, client, 'tokenadmin',
|
|
['endpoints.view', 'endpoints.command',
|
|
'apitokens.create'], admin=True)
|
|
|
|
minted = client.post('/api/apitokens', headers=ownerheaders,
|
|
json={'name': 'agent',
|
|
'scopes': ['endpoints.command']})
|
|
secret = minted.get_json()['data']['secret']
|
|
headers = {'Authorization': f'Bearer {secret}'}
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'system_restart',
|
|
'policyoverride': True,
|
|
'overridereason': 'leaked token tries it on'})
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_a_hold_suppresses_desired_state_reconciliation(client, db):
|
|
"""Without this the reconciler undoes the override on the next check-in."""
|
|
from plugins.computers.models import PolicyActionHold
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db, 'desiredbox')
|
|
|
|
from plugins.computers.models import (ConfigurationPolicy,
|
|
ConfigurationPolicyAction,
|
|
BusinessPolicy, BusinessPolicyItem)
|
|
policy = ConfigurationPolicy(name='Keep it running', scopejson=None)
|
|
db.session.add(policy)
|
|
db.session.flush()
|
|
db.session.add(ConfigurationPolicyAction(
|
|
configurationpolicyid=policy.configurationpolicyid,
|
|
action='service_start', mode='desired',
|
|
desiredstate={'running': True}))
|
|
business = BusinessPolicy(name='Desired fleet')
|
|
db.session.add(business)
|
|
db.session.flush()
|
|
db.session.add(BusinessPolicyItem(
|
|
businesspolicyid=business.businesspolicyid,
|
|
configurationpolicyid=policy.configurationpolicyid))
|
|
db.session.commit()
|
|
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
db.session.add(PolicyActionHold(
|
|
assetid=assetid, action='service_start',
|
|
expiresat=datetime.utcnow() + timedelta(minutes=60),
|
|
reason='maintenance window'))
|
|
db.session.commit()
|
|
|
|
effective = effective_policy_for(assetid)
|
|
assert 'service_start' not in effective.desired_actions()
|
|
# A hold suspends drift correction; it does not revoke the permission to
|
|
# run the action by hand.
|
|
assert 'service_start' in effective.allowed_actions()
|
|
|
|
|
|
def test_an_expired_hold_stops_suppressing(client, db):
|
|
"""Policy resumes on its own. Nobody has to remember to clear a hold, and a
|
|
stalled sweeper cannot silently disable reconciliation forever."""
|
|
from plugins.computers.models import PolicyActionHold
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db, 'expiredbox')
|
|
|
|
from plugins.computers.models import (ConfigurationPolicy,
|
|
ConfigurationPolicyAction,
|
|
BusinessPolicy, BusinessPolicyItem)
|
|
policy = ConfigurationPolicy(name='Still running', scopejson=None)
|
|
db.session.add(policy)
|
|
db.session.flush()
|
|
db.session.add(ConfigurationPolicyAction(
|
|
configurationpolicyid=policy.configurationpolicyid,
|
|
action='service_start', mode='desired',
|
|
desiredstate={'running': True}))
|
|
business = BusinessPolicy(name='Expiry fleet')
|
|
db.session.add(business)
|
|
db.session.flush()
|
|
db.session.add(BusinessPolicyItem(
|
|
businesspolicyid=business.businesspolicyid,
|
|
configurationpolicyid=policy.configurationpolicyid))
|
|
db.session.add(PolicyActionHold(
|
|
assetid=assetid, action='service_start',
|
|
expiresat=datetime.utcnow() - timedelta(minutes=1),
|
|
reason='window that already closed'))
|
|
db.session.commit()
|
|
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_active_holds_are_visible_to_a_non_admin_viewer(client, db):
|
|
"""A hold explains drift, so it must not be admin-only reading.
|
|
|
|
An operator who can see the machine but not the hold is left debugging
|
|
suppressed reconciliation as if it were a fault.
|
|
"""
|
|
from plugins.computers.models import PolicyActionHold
|
|
|
|
assetid = _endpoint(db)
|
|
db.session.add(PolicyActionHold(
|
|
assetid=assetid, action='service_stop',
|
|
expiresat=datetime.utcnow() + timedelta(minutes=60),
|
|
createdbyusername='nightadmin',
|
|
reason='vendor on site'))
|
|
db.session.commit()
|
|
|
|
headers = _user(db, client, 'viewer', ['endpoints.view'])
|
|
response = client.get(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
holds = response.get_json()['data']
|
|
assert len(holds) == 1
|
|
assert holds[0]['action'] == 'service_stop'
|
|
assert holds[0]['createdbyusername'] == 'nightadmin'
|
|
assert holds[0]['reason'] == 'vendor on site'
|
|
|
|
|
|
def test_expired_holds_are_not_reported_as_active(client, db):
|
|
"""Expiry is evaluated on read, matching the resolver.
|
|
|
|
If the route reported expired rows the banner would claim enforcement is
|
|
paused long after it resumed - worse than showing nothing.
|
|
"""
|
|
from plugins.computers.models import PolicyActionHold
|
|
|
|
assetid = _endpoint(db)
|
|
db.session.add(PolicyActionHold(
|
|
assetid=assetid, action='service_stop',
|
|
expiresat=datetime.utcnow() - timedelta(minutes=1),
|
|
createdbyusername='nightadmin',
|
|
reason='window that already closed'))
|
|
db.session.commit()
|
|
|
|
headers = _user(db, client, 'viewer2', ['endpoints.view'])
|
|
response = client.get(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data'] == []
|
|
|
|
|
|
def _desired_policy(db, assetid, action='service_start'):
|
|
"""A policy putting one action under desired-state enforcement."""
|
|
from plugins.computers.models import (ConfigurationPolicy,
|
|
ConfigurationPolicyAction,
|
|
BusinessPolicy, BusinessPolicyItem)
|
|
policy = ConfigurationPolicy(name=f'Desired {action}', scopejson=None)
|
|
db.session.add(policy)
|
|
db.session.flush()
|
|
db.session.add(ConfigurationPolicyAction(
|
|
configurationpolicyid=policy.configurationpolicyid,
|
|
action=action, mode='desired', desiredstate={'running': True}))
|
|
business = BusinessPolicy(name=f'Fleet {action}')
|
|
db.session.add(business)
|
|
db.session.flush()
|
|
db.session.add(BusinessPolicyItem(
|
|
businesspolicyid=business.businesspolicyid,
|
|
configurationpolicyid=policy.configurationpolicyid))
|
|
db.session.commit()
|
|
|
|
|
|
def test_an_admin_can_place_a_hold_directly(client, db):
|
|
"""The hold must be reachable deliberately, not only by tripping a denial."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdadmin',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'action': 'service_start',
|
|
'reason': 'vendor on site',
|
|
'minutes': 60})
|
|
assert response.status_code == 200, response.get_json()
|
|
holds = response.get_json()['data']
|
|
assert [h['action'] for h in holds] == ['service_start']
|
|
|
|
# The point of a hold: reconciliation stops asking for this action.
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_a_non_admin_cannot_place_a_hold(client, db):
|
|
"""endpoints.command queues work; it does not switch off enforcement."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdoperator',
|
|
['endpoints.view', 'endpoints.command'])
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'action': 'service_start',
|
|
'reason': 'trying it on'})
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_a_hold_requires_a_reason(client, db):
|
|
"""An unexplained hold is an unattributable outage waiting to happen."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdadmin2',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'action': 'service_start', 'reason': ' '})
|
|
assert response.status_code == 400, response.get_json()
|
|
|
|
|
|
def test_holding_a_non_desired_action_is_refused(client, db):
|
|
"""A hold on a gate-mode action would suppress nothing.
|
|
|
|
Accepting it would report a hold that has no effect, which is worse than
|
|
refusing outright.
|
|
"""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
headers = _user(db, client, 'holdadmin3',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'action': 'mtconnect_status',
|
|
'reason': 'not under enforcement'})
|
|
assert response.status_code == 400, response.get_json()
|
|
assert 'nothing to hold' in \
|
|
response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_an_admin_can_release_a_hold_early(client, db):
|
|
"""Release resumes reconciliation without waiting for expiry."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdadmin4',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
|
json={'action': 'service_start', 'reason': 'swapping a disk'})
|
|
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
|
|
|
response = client.delete(
|
|
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data'] == []
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_releasing_a_hold_keeps_the_audit_row(client, db):
|
|
"""Release soft-deletes: the hold IS the record of what was suppressed."""
|
|
from plugins.computers.models import PolicyActionHold
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdadmin5',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
|
json={'action': 'service_start', 'reason': 'keep this record'})
|
|
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
|
|
headers=headers)
|
|
|
|
row = PolicyActionHold.query.filter_by(assetid=assetid,
|
|
action='service_start').first()
|
|
assert row is not None
|
|
assert row.isactive is False
|
|
assert row.reason == 'keep this record'
|
|
|
|
|
|
def test_holdable_actions_lists_desired_actions_with_hold_state(client, db):
|
|
"""The picker must keep showing an action after it is held.
|
|
|
|
Built from desired_actions() it would vanish on being held, making the
|
|
current state unreadable.
|
|
"""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'holdadmin6',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
|
|
headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data'] == [
|
|
{'action': 'service_start', 'held': False}]
|
|
|
|
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
|
json={'action': 'service_start', 'reason': 'still listed'})
|
|
|
|
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
|
|
headers=headers)
|
|
assert response.get_json()['data'] == [
|
|
{'action': 'service_start', 'held': True}]
|
|
|
|
|
|
def _lock_url(assetid):
|
|
return f'/api/endpoints/{assetid}/lock'
|
|
|
|
|
|
def test_a_lock_refuses_a_non_admin_command(client, db):
|
|
"""The whole point: a locked endpoint stops other people acting on it.
|
|
|
|
No policy is enabled here, so nothing but the lock can be doing the
|
|
refusing - otherwise this test would pass for the wrong reason.
|
|
"""
|
|
assetid = _endpoint(db)
|
|
adminheaders = _user(db, client, 'lockadmin',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=adminheaders,
|
|
json={'reason': 'mid repair'})
|
|
|
|
headers = _user(db, client, 'lockoperator',
|
|
['endpoints.view', 'endpoints.command'])
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler'})
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_a_lock_refusal_names_the_owner_and_reason(client, db):
|
|
"""A bare "forbidden" sends an operator hunting through policy for a cause
|
|
that is not there. The refusal has to say who to ask and why."""
|
|
assetid = _endpoint(db)
|
|
adminheaders = _user(db, client, 'lockadmin2',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=adminheaders,
|
|
json={'reason': 'validated part running'})
|
|
|
|
headers = _user(db, client, 'lockoperator2',
|
|
['endpoints.view', 'endpoints.command'])
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler'})
|
|
message = response.get_json()['data']['error']['message']
|
|
assert 'lockadmin2' in message
|
|
assert 'validated part running' in message
|
|
|
|
|
|
def test_an_admin_acts_through_a_lock_without_an_override(client, db):
|
|
"""The lock gates WHO may act, not WHICH actions exist, so there is no
|
|
policy decision for an admin to break past. Requiring break-glass here would
|
|
mean an admin overriding their own quarantine to do the repair it was set
|
|
for."""
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockadmin3',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'mine to work on'})
|
|
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler'})
|
|
assert response.status_code == 201, response.get_json()
|
|
|
|
|
|
def test_a_lock_cannot_be_broken_by_a_policy_override(client, db):
|
|
"""The two gates are independent and the lock is the outer one.
|
|
|
|
policyoverride exists to push past a POLICY denial. If it also dissolved a
|
|
lock, any non-admin who learned the flag could act on a quarantined machine,
|
|
and the lock would be advisory.
|
|
"""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_deny_everything(db, assetid)
|
|
adminheaders = _user(db, client, 'lockadmin4',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=adminheaders,
|
|
json={'reason': 'do not touch'})
|
|
|
|
headers = _user(db, client, 'lockoperator4',
|
|
['endpoints.view', 'endpoints.command'])
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler',
|
|
'policyoverride': True,
|
|
'overridereason': 'trying to slip past'})
|
|
assert response.status_code == 403, response.get_json()
|
|
assert 'locked' in response.get_json()['data']['error']['message'].lower()
|
|
|
|
|
|
def test_a_non_admin_cannot_place_a_lock(client, db):
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockoperator5',
|
|
['endpoints.view', 'endpoints.command'])
|
|
|
|
response = client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'not my call'})
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_a_lock_requires_a_reason(client, db):
|
|
"""The reason is the message a blocked operator reads, not paperwork."""
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockadmin6',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': ' '})
|
|
assert response.status_code == 400, response.get_json()
|
|
|
|
|
|
def test_locking_twice_does_not_stack_a_second_lock(client, db):
|
|
"""Two active locks on one endpoint is not a state the table should hold,
|
|
and the caller who locked a locked machine got what they asked for."""
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockadmin7',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
first = client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'first'})
|
|
second = client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'second'})
|
|
assert second.status_code == 200, second.get_json()
|
|
# The original stands - the second call did not silently replace the reason
|
|
# the first admin recorded.
|
|
assert second.get_json()['data']['endpointlockid'] == \
|
|
first.get_json()['data']['endpointlockid']
|
|
assert second.get_json()['data']['reason'] == 'first'
|
|
|
|
|
|
def test_releasing_a_lock_restores_non_admin_commands(client, db):
|
|
assetid = _endpoint(db)
|
|
adminheaders = _user(db, client, 'lockadmin8',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=adminheaders,
|
|
json={'reason': 'briefly'})
|
|
client.delete(_lock_url(assetid), headers=adminheaders)
|
|
|
|
headers = _user(db, client, 'lockoperator8',
|
|
['endpoints.view', 'endpoints.command'])
|
|
response = client.post(_url(assetid), headers=headers,
|
|
json={'action': 'service_status',
|
|
'target': 'spooler'})
|
|
assert response.status_code == 201, response.get_json()
|
|
|
|
|
|
def test_releasing_a_lock_keeps_the_row_as_history(client, db):
|
|
"""Released by stamping releasedat, not by deleting. The record of who
|
|
quarantined a machine is most wanted after the lock is gone and no longer
|
|
there to explain itself."""
|
|
from plugins.endpoints.models import EndpointLock
|
|
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockadmin9',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'keep this record'})
|
|
client.delete(_lock_url(assetid), headers=headers)
|
|
|
|
row = EndpointLock.query.filter_by(assetid=assetid).first()
|
|
assert row is not None
|
|
assert row.reason == 'keep this record'
|
|
assert row.lockedbyusername == 'lockadmin9'
|
|
assert row.releasedat is not None
|
|
assert row.releasedbyusername == 'lockadmin9'
|
|
|
|
|
|
def test_releasing_an_unlocked_endpoint_is_a_404(client, db):
|
|
"""So a caller can tell "unlocked it" from "there was nothing to unlock"."""
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockadmin10',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.delete(_lock_url(assetid), headers=headers)
|
|
assert response.status_code == 404, response.get_json()
|
|
|
|
|
|
def test_a_non_admin_can_read_the_lock(client, db):
|
|
"""Read-gated, not admin-gated. An operator who cannot see the lock is left
|
|
with a bare refusal and files a ticket about a machine that is working."""
|
|
assetid = _endpoint(db)
|
|
adminheaders = _user(db, client, 'lockadmin11',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=adminheaders,
|
|
json={'reason': 'visible to all'})
|
|
|
|
headers = _user(db, client, 'lockoperator11', ['endpoints.view'])
|
|
response = client.get(_lock_url(assetid), headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data']['reason'] == 'visible to all'
|
|
|
|
|
|
def test_an_unlocked_endpoint_reports_no_lock(client, db):
|
|
assetid = _endpoint(db)
|
|
headers = _user(db, client, 'lockoperator12', ['endpoints.view'])
|
|
|
|
response = client.get(_lock_url(assetid), headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
# success_response(None) omits data entirely rather than emitting null, so
|
|
# "no lock" is an absent key. The frontend reads it as response.data?.data
|
|
# || null, which lands on the same answer either way.
|
|
assert response.get_json().get('data') is None
|
|
|
|
|
|
def test_a_lock_does_not_suppress_policy_reconciliation(client, db):
|
|
"""The two axes stay separate. A lock gates callers; it must not reach into
|
|
the resolver and start suppressing desired-state actions, which is the hold's
|
|
job and carries an expiry a lock does not have."""
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'lockadmin13',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(_lock_url(assetid), headers=headers,
|
|
json={'reason': 'locked but still reconciled'})
|
|
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_an_admin_can_hold_several_actions_at_once(client, db):
|
|
"""One dialog submission covers the whole job, not one action per trip.
|
|
|
|
An admin about to work on a machine usually needs several actions pinned,
|
|
and making them repeat the dialog invites stopping halfway.
|
|
"""
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid, action='service_start')
|
|
_desired_policy(db, assetid, action='service_stop')
|
|
headers = _user(db, client, 'multiadmin1',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'actions': ['service_start', 'service_stop'],
|
|
'reason': 'stripping the enclosure',
|
|
'minutes': 60})
|
|
assert response.status_code == 200, response.get_json()
|
|
held = {h['action'] for h in response.get_json()['data']}
|
|
assert held == {'service_start', 'service_stop'}
|
|
|
|
desired = effective_policy_for(assetid).desired_actions()
|
|
assert 'service_start' not in desired
|
|
assert 'service_stop' not in desired
|
|
|
|
|
|
def test_a_multi_action_hold_is_all_or_nothing(client, db):
|
|
"""One bad name rejects the request whole.
|
|
|
|
Partial success is the wrong failure mode on a break-glass control: the
|
|
admin walks away believing the machine is pinned, and the reconciler undoes
|
|
the work covered by the holds that never landed.
|
|
"""
|
|
from plugins.computers.models import PolicyActionHold
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid, action='service_start')
|
|
headers = _user(db, client, 'multiadmin2',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(
|
|
f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
# service_start is holdable; mtconnect_status is not under enforcement.
|
|
json={'actions': ['service_start', 'mtconnect_status'],
|
|
'reason': 'half of this is invalid'})
|
|
assert response.status_code == 400, response.get_json()
|
|
|
|
# The valid half must NOT have been written on the way to the refusal.
|
|
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
|
|
|
|
|
|
def test_a_zero_minute_hold_never_expires(client, db):
|
|
"""0 means indefinite: it outlives any window a dated hold would have."""
|
|
from plugins.computers.models import PolicyActionHold
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'indefadmin1',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'actions': ['service_start'],
|
|
'reason': 'awaiting a replacement part',
|
|
'minutes': 0})
|
|
assert response.status_code == 200, response.get_json()
|
|
|
|
row = PolicyActionHold.query.filter_by(assetid=assetid,
|
|
action='service_start').first()
|
|
assert row.expiresat is None
|
|
# Reported active and honoured by the resolver despite having no expiry.
|
|
assert response.get_json()['data'][0]['expiresat'] is None
|
|
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_an_indefinite_hold_can_still_be_released(client, db):
|
|
"""The one hold that never self-clears must be clearable by hand.
|
|
|
|
Without a null branch in the release query this would be the only kind of
|
|
hold the release button could not touch - permanently stuck.
|
|
"""
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'indefadmin2',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
|
json={'actions': ['service_start'], 'reason': 'indefinite',
|
|
'minutes': 0})
|
|
|
|
response = client.delete(
|
|
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_a_negative_hold_window_is_refused(client, db):
|
|
"""A negative window places an already-expired hold.
|
|
|
|
That reads as success in the UI while suppressing nothing - the silent
|
|
failure the holdable-action check exists to prevent.
|
|
"""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'indefadmin3',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'actions': ['service_start'],
|
|
'reason': 'backwards', 'minutes': -30})
|
|
assert response.status_code == 400, response.get_json()
|
|
|
|
|
|
def test_re_holding_an_active_action_does_not_stack_rows(client, db):
|
|
"""Idempotent, matching the endpoint lock.
|
|
|
|
A duplicate row would have to be released once per hidden copy before
|
|
reconciliation actually resumed.
|
|
"""
|
|
from plugins.computers.models import PolicyActionHold
|
|
from plugins.computers.services.policyresolver import effective_policy_for
|
|
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'dupeadmin1',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
for _ in range(2):
|
|
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
|
json={'actions': ['service_start'], 'reason': 'twice'})
|
|
|
|
assert PolicyActionHold.query.filter_by(assetid=assetid,
|
|
action='service_start').count() == 1
|
|
|
|
# One release is therefore enough to resume reconciliation.
|
|
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
|
|
headers=headers)
|
|
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
|
|
|
|
|
def test_a_single_action_hold_payload_still_works(client, db):
|
|
"""The pre-list payload shape stays accepted so older callers keep working."""
|
|
_enable_policies(db)
|
|
assetid = _endpoint(db)
|
|
_desired_policy(db, assetid)
|
|
headers = _user(db, client, 'legacyadmin1',
|
|
['endpoints.view', 'endpoints.command'], admin=True)
|
|
|
|
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
|
headers=headers,
|
|
json={'action': 'service_start',
|
|
'reason': 'legacy payload'})
|
|
assert response.status_code == 200, response.get_json()
|
|
assert [h['action'] for h in response.get_json()['data']] == \
|
|
['service_start']
|