Add multi-action policy holds and exempt collect_now from policy
Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds. collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
This commit is contained in:
181
tests/test_plugins/test_computers_policy_authoring_gate.py
Normal file
181
tests/test_plugins/test_computers_policy_authoring_gate.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Authoring a policy requires the permission to run what the policy carries.
|
||||
|
||||
A policy is a deferred instruction to run something. Gating only the enqueue
|
||||
route (plugins/endpoints/api/routes.py) left authoring wide open: computers.edit
|
||||
alone could author a policy carrying run_script and let the agent execute it on
|
||||
every machine in scope, which undoes the whole point of splitting the big four.
|
||||
|
||||
These tests pin the four ways a policy's EFFECT can change - authoring actions,
|
||||
widening scope, reactivating, and composing - so a future edit cannot quietly
|
||||
reopen any one of them.
|
||||
"""
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
CONFIG_URL = '/api/computers/policies/configuration'
|
||||
BUSINESS_URL = '/api/computers/policies/business'
|
||||
|
||||
|
||||
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 _user_with_perms(client, db, username, permnames):
|
||||
"""A non-admin holding exactly permnames. Returns login headers."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
Permission.seed_entries([
|
||||
('endpoints.runscript', 'Push scripts', 'endpoints'),
|
||||
('endpoints.restart', 'Remote restart', 'endpoints'),
|
||||
])
|
||||
db.session.flush()
|
||||
role = Role(rolename=username + 'role')
|
||||
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()
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = login.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _policy_editor(client, db, username='policyeditor', extra=()):
|
||||
return _user_with_perms(
|
||||
client, db, username,
|
||||
['computers.view', 'computers.create', 'computers.edit',
|
||||
'computers.delete', *extra])
|
||||
|
||||
|
||||
def _make_policy(db, name, action='run_script', scope=None, isactive=True):
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction)
|
||||
policy = ConfigurationPolicy(name=name, scopejson=scope, isactive=isactive)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid, action=action))
|
||||
db.session.commit()
|
||||
return policy.configurationpolicyid
|
||||
|
||||
|
||||
def test_authoring_run_script_needs_the_runscript_permission(client, db):
|
||||
"""The core escalation: computers.create alone must not mint a policy that
|
||||
runs arbitrary scripts."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.post(CONFIG_URL, headers=headers, json={
|
||||
'name': 'Sneaky', 'actions': [{'action': 'run_script'}],
|
||||
})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'endpoints.runscript' in message
|
||||
|
||||
|
||||
def test_authoring_run_script_is_allowed_with_the_permission(client, db):
|
||||
"""Holding the grant makes it ordinary work, not a special case."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db, extra=['endpoints.runscript'])
|
||||
|
||||
response = client.post(CONFIG_URL, headers=headers, json={
|
||||
'name': 'Legitimate', 'actions': [{'action': 'run_script'}],
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_widening_scope_on_an_existing_policy_is_gated(client, db):
|
||||
"""Scope widening changes what an already-authored action DOES without
|
||||
touching the action list. Gating the payload alone would miss it."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Narrow', scope={'type': 'pctype', 'id': 1})
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
|
||||
json={'scopejson': None})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_renaming_a_policy_is_not_gated(client, db):
|
||||
"""A rename changes nothing that runs. Gating it would be security theatre
|
||||
that stops people tidying up."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Old Name')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
|
||||
json={'name': 'New Name',
|
||||
'description': 'clearer wording'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
|
||||
def test_reactivating_a_policy_is_gated_but_deactivating_is_not(client, db):
|
||||
"""Reactivation re-arms the action. Deactivation cannot widen anything, and
|
||||
refusing it would strand a live policy nobody present can switch off."""
|
||||
_enable_policies(db)
|
||||
dormant = _make_policy(db, 'Dormant', isactive=False)
|
||||
live = _make_policy(db, 'Live', isactive=True)
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
blocked = client.put(f'{CONFIG_URL}/{dormant}', headers=headers,
|
||||
json={'isactive': True})
|
||||
assert blocked.status_code == 403, blocked.get_json()
|
||||
|
||||
allowed = client.put(f'{CONFIG_URL}/{live}', headers=headers,
|
||||
json={'isactive': False})
|
||||
assert allowed.status_code == 200, allowed.get_json()
|
||||
|
||||
|
||||
def test_composing_a_privileged_policy_into_a_business_policy_is_gated(
|
||||
client, db):
|
||||
"""Composition is the quiet escalation: attaching a configuration policy
|
||||
runs its actions without the composer ever naming them."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Privileged')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.post(BUSINESS_URL, headers=headers, json={
|
||||
'name': 'Wrapper',
|
||||
'items': [{'configurationpolicyid': policyid}],
|
||||
})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_deleting_a_policy_carrying_an_elevated_action_is_gated(client, db):
|
||||
"""Deleting a gate policy REMOVES a restriction, so it changes what
|
||||
endpoints will run just as surely as authoring one does."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Restriction')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.delete(f'{CONFIG_URL}/{policyid}', headers=headers)
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_the_action_catalog_marks_what_the_caller_may_author(client, db):
|
||||
"""Marked, not hidden. Hiding would make the editor look like the capability
|
||||
does not exist; marking says it exists and is not yours."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db, extra=['endpoints.restart'])
|
||||
|
||||
response = client.get('/api/computers/policies/actions', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
catalog = {row['action']: row for row in response.get_json()['data']}
|
||||
|
||||
assert catalog['system_restart']['mayauthor'] is True
|
||||
assert catalog['run_script']['mayauthor'] is False
|
||||
assert catalog['run_script']['requiredpermission'] == 'endpoints.runscript'
|
||||
885
tests/test_plugins/test_endpoints_policy_override.py
Normal file
885
tests/test_plugins/test_endpoints_policy_override.py
Normal file
@@ -0,0 +1,885 @@
|
||||
"""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_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']
|
||||
Reference in New Issue
Block a user