Aug 26 - Enhance security 4
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Regression tests for emergency-access visibility (finding #9).
|
||||
|
||||
Two problems, both leaving the grantor blind to activity on their own vault:
|
||||
|
||||
1. Audit entries were written only under the acting user's id, and
|
||||
/api/auth/audit-log filters by user_id — so a grantee could request access
|
||||
and retrieve the snapshot without a single line appearing in the grantor's
|
||||
log or security dashboard.
|
||||
2. Retrieval left no trace on the record at all: status stayed 'pending' and
|
||||
nothing counted, so repeated fetches were indistinguishable from none.
|
||||
|
||||
Retrieval is deliberately still permitted after the first time — the grantor may
|
||||
be unable to re-provision, which is the whole premise — so the fix is visibility
|
||||
and revocability, not blocking.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.emergency_access import EmergencyAccess
|
||||
from tests.conftest import auth_headers, make_user
|
||||
|
||||
GRANTOR = 'owner@example.com'
|
||||
GRANTEE = 'trusted@example.com'
|
||||
|
||||
|
||||
def _pair(client):
|
||||
"""Create grantor + grantee, returns (grantor_token, grantee_token)."""
|
||||
g_token, _ = make_user(client, GRANTOR, 'HASH-O', 'SALT-O')
|
||||
t_token, _ = make_user(client, GRANTEE, 'HASH-T', 'SALT-T')
|
||||
return g_token, t_token
|
||||
|
||||
|
||||
def _grant(client, grantor_token, grantee_token, wait_days=7):
|
||||
res = client.post('/api/emergency', headers=auth_headers(grantor_token),
|
||||
json={'grantee_email': GRANTEE, 'wait_days': wait_days})
|
||||
assert res.status_code == 201, res.get_json()
|
||||
ea_id = res.get_json()['id']
|
||||
|
||||
assert client.post(f'/api/emergency/{ea_id}/accept',
|
||||
headers=auth_headers(grantee_token)).status_code == 200
|
||||
assert client.post(f'/api/emergency/{ea_id}/provide',
|
||||
headers=auth_headers(grantor_token),
|
||||
json={'enc_vault': '[{"id":1,"enc_data":"X","iv":"Y","enc_name":"N","iv_name":"I"}]'}
|
||||
).status_code == 200
|
||||
return ea_id
|
||||
|
||||
|
||||
def _grantor_log(client, token):
|
||||
res = client.get('/api/auth/audit-log?limit=200', headers=auth_headers(token))
|
||||
assert res.status_code == 200
|
||||
return res.get_json()['entries']
|
||||
|
||||
|
||||
def _elapse_wait(ea_id):
|
||||
"""Backdate the request so the wait period has passed."""
|
||||
ea = db.session.get(EmergencyAccess, ea_id)
|
||||
ea.request_initiated_at = (
|
||||
datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(days=ea.wait_days + 1)
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_access_request_appears_in_the_grantors_audit_log(client, app):
|
||||
"""The grantor has wait_days to notice and deny — they must be able to see it."""
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
|
||||
assert client.post(f'/api/emergency/{ea_id}/request',
|
||||
headers=auth_headers(t_token)).status_code == 200
|
||||
|
||||
entries = _grantor_log(client, g_token)
|
||||
requests = [e for e in entries if e['action'] == 'emergency_access.request']
|
||||
assert requests, 'the access request is invisible in the grantor audit log'
|
||||
assert 'ACTION REQUIRED' in requests[0]['detail']
|
||||
assert GRANTEE in requests[0]['detail']
|
||||
|
||||
|
||||
def test_vault_retrieval_appears_in_the_grantors_audit_log(client, app):
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
_elapse_wait(ea_id)
|
||||
|
||||
assert client.get(f'/api/emergency/{ea_id}/vault',
|
||||
headers=auth_headers(t_token)).status_code == 200
|
||||
|
||||
entries = _grantor_log(client, g_token)
|
||||
retrievals = [e for e in entries
|
||||
if e['action'] == 'emergency_access.vault_retrieved']
|
||||
assert retrievals, 'vault retrieval is invisible in the grantor audit log'
|
||||
assert GRANTEE in retrievals[0]['detail']
|
||||
|
||||
|
||||
def test_retrieval_is_counted_and_exposed_to_the_grantor(client, app):
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
_elapse_wait(ea_id)
|
||||
|
||||
grants = client.get('/api/emergency', headers=auth_headers(g_token)).get_json()['grants']
|
||||
assert grants[0]['vault_retrieval_count'] == 0
|
||||
assert grants[0]['vault_retrieved_at'] is None
|
||||
|
||||
for _ in range(3):
|
||||
assert client.get(f'/api/emergency/{ea_id}/vault',
|
||||
headers=auth_headers(t_token)).status_code == 200
|
||||
|
||||
grants = client.get('/api/emergency', headers=auth_headers(g_token)).get_json()['grants']
|
||||
assert grants[0]['vault_retrieval_count'] == 3, 'repeated retrieval not counted'
|
||||
assert grants[0]['vault_retrieved_at'] is not None, 'first retrieval not timestamped'
|
||||
|
||||
|
||||
def test_first_retrieval_timestamp_does_not_move(client, app):
|
||||
"""vault_retrieved_at records FIRST access, so it cannot be reset by re-fetching."""
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
_elapse_wait(ea_id)
|
||||
|
||||
client.get(f'/api/emergency/{ea_id}/vault', headers=auth_headers(t_token))
|
||||
first = db.session.get(EmergencyAccess, ea_id).vault_retrieved_at
|
||||
client.get(f'/api/emergency/{ea_id}/vault', headers=auth_headers(t_token))
|
||||
assert db.session.get(EmergencyAccess, ea_id).vault_retrieved_at == first
|
||||
|
||||
|
||||
def test_grantor_can_still_revoke_after_retrieval(client, app):
|
||||
"""Visibility is only useful if the grantor can act on it."""
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
_elapse_wait(ea_id)
|
||||
client.get(f'/api/emergency/{ea_id}/vault', headers=auth_headers(t_token))
|
||||
|
||||
assert client.delete(f'/api/emergency/{ea_id}',
|
||||
headers=auth_headers(g_token)).status_code == 200
|
||||
assert client.get(f'/api/emergency/{ea_id}/vault',
|
||||
headers=auth_headers(t_token)).status_code == 404
|
||||
|
||||
|
||||
def test_acceptance_is_visible_to_the_grantor(client, app):
|
||||
g_token, t_token = _pair(client)
|
||||
_grant(client, g_token, t_token)
|
||||
|
||||
accepts = [e for e in _grantor_log(client, g_token)
|
||||
if e['action'] == 'emergency_access.accept']
|
||||
assert accepts, 'grantee acceptance is invisible to the grantor'
|
||||
|
||||
|
||||
def test_retrieval_before_the_wait_elapses_is_still_refused(client, app):
|
||||
"""The wait period remains the gate — none of this loosens it."""
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
|
||||
res = client.get(f'/api/emergency/{ea_id}/vault', headers=auth_headers(t_token))
|
||||
assert res.status_code == 403
|
||||
assert db.session.get(EmergencyAccess, ea_id).vault_retrieval_count == 0
|
||||
|
||||
|
||||
def test_denying_a_request_stops_retrieval(client, app):
|
||||
g_token, t_token = _pair(client)
|
||||
ea_id = _grant(client, g_token, t_token)
|
||||
client.post(f'/api/emergency/{ea_id}/request', headers=auth_headers(t_token))
|
||||
|
||||
assert client.post(f'/api/emergency/{ea_id}/deny',
|
||||
headers=auth_headers(g_token)).status_code == 200
|
||||
_elapse_wait(ea_id) # even with time passed, the request was cancelled
|
||||
|
||||
res = client.get(f'/api/emergency/{ea_id}/vault', headers=auth_headers(t_token))
|
||||
assert res.status_code == 403
|
||||
Reference in New Issue
Block a user