feat(cells): Phase 1 — permission sync between connected PICs

When PIC A updates service sharing permissions, it immediately pushes
the mirrored state to PIC B over the WireGuard tunnel so B's UI shows
what A is sharing with it in real time.

Architecture:
- Push model: update_permissions() → _push_permissions_to_remote() →
  POST /api/cells/peer-sync/permissions on remote cell
- Auth: source IP must be inside a known cell's vpn_subnet (WireGuard
  tunnel proves identity) + body's from_public_key must match stored key
- Mirror semantics: our inbound (what we share) → their outbound view
- Non-fatal: push failures set pending_push=True; replay_pending_pushes()
  retries at startup so offline cells catch up on reconnect
- add_connection() also pushes initial state so remote sees permissions
  immediately on the first connect

New fields on cell_links.json records (lazy-migrated):
  remote_api_url, last_push_status, last_push_at, last_push_error,
  pending_push, last_remote_update_at

New endpoint: POST /api/cells/peer-sync/permissions

30 new tests (1101 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 13:12:30 -04:00
parent 37d023659a
commit a3d0cd5a48
5 changed files with 741 additions and 13 deletions
+270
View File
@@ -398,3 +398,273 @@ class TestLoadMigration(unittest.TestCase):
with open(links_file) as f:
raw = json.load(f)
self.assertIn('permissions', raw[0])
class TestPermissionSync(unittest.TestCase):
"""Tests for Phase 1: permission sync between connected PIC cells."""
INVITE = {
'cell_name': 'office',
'public_key': 'officepubkey=',
'endpoint': '5.6.7.8:51820',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'version': 1,
}
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.wg = _make_wg_mock()
self.nm = _make_nm_mock()
self.mgr = CellLinkManager(self.test_dir, self.test_dir, self.wg, self.nm)
def tearDown(self):
shutil.rmtree(self.test_dir)
def _add_office(self, push_ok=True):
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': push_ok, 'error': None if push_ok else 'conn refused'}), \
patch('firewall_manager.apply_cell_rules'):
return self.mgr.add_connection(self.INVITE)
# ── add_connection ────────────────────────────────────────────────────────
def test_add_connection_includes_sync_fields(self):
link = self._add_office()
self.assertIn('remote_api_url', link)
self.assertIn('pending_push', link)
self.assertIn('last_push_status', link)
self.assertIn('last_push_at', link)
self.assertIn('last_remote_update_at', link)
def test_add_connection_sets_remote_api_url_from_dns_ip(self):
link = self._add_office()
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
def test_add_connection_triggers_push(self):
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
patch('firewall_manager.apply_cell_rules'):
self.mgr.add_connection(self.INVITE)
push_mock.assert_called_once()
call_args = push_mock.call_args[0]
self.assertEqual(call_args[1], 'home') # from_cell
self.assertEqual(call_args[2], 'homepubkey=') # from_public_key
def test_add_connection_push_failure_does_not_abort_add(self):
link = self._add_office(push_ok=False)
conns = self.mgr.list_connections()
self.assertEqual(len(conns), 1)
self.assertEqual(conns[0]['cell_name'], 'office')
self.assertTrue(conns[0]['pending_push'])
def test_add_connection_push_success_clears_pending(self):
self._add_office(push_ok=True)
link = self.mgr.list_connections()[0]
self.assertFalse(link['pending_push'])
self.assertEqual(link['last_push_status'], 'ok')
# ── update_permissions ────────────────────────────────────────────────────
def test_update_permissions_push_succeeds_clears_pending(self):
self._add_office()
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock), \
patch('firewall_manager.apply_cell_rules'):
self.mgr.update_permissions('office',
{'calendar': True}, {'files': False})
link = self.mgr.list_connections()[0]
self.assertFalse(link['pending_push'])
self.assertEqual(link['last_push_status'], 'ok')
self.assertIsNotNone(link['last_push_at'])
def test_update_permissions_push_failure_keeps_local_save(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': False, 'error': 'timeout'}), \
patch('firewall_manager.apply_cell_rules'):
result = self.mgr.update_permissions('office',
{'calendar': True}, {})
# Local save must have happened — calendar is True
self.assertTrue(result['permissions']['inbound']['calendar'])
link = self.mgr.list_connections()[0]
self.assertTrue(link['pending_push'])
self.assertEqual(link['last_push_status'], 'failed')
def test_update_permissions_does_not_raise_on_push_exception(self):
self._add_office()
with patch('cell_link_manager.CellLinkManager._local_identity',
side_effect=RuntimeError('no app context')), \
patch('firewall_manager.apply_cell_rules'):
# Must not raise
result = self.mgr.update_permissions('office', {}, {})
self.assertIn('permissions', result)
# ── _push_permissions_to_remote (unit) ────────────────────────────────────
def test_push_mirrors_inbound_outbound(self):
"""Our inbound (what we share) must become their outbound in the body."""
self._add_office()
link = self.mgr.list_connections()[0]
link['permissions'] = {
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False},
}
sent_body = {}
def fake_urlopen(req, timeout=None):
import json as _j
sent_body.update(_j.loads(req.data))
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
resp.status = 200
return resp
with patch('urllib.request.urlopen', fake_urlopen):
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertTrue(result['ok'])
pushed_perms = sent_body['permissions']
# Our inbound=calendar:True → their outbound=calendar:True
self.assertTrue(pushed_perms['outbound']['calendar'])
# Our outbound=files:True → their inbound=files:True
self.assertTrue(pushed_perms['inbound']['files'])
def test_push_http_error_returns_not_ok(self):
self._add_office()
link = self.mgr.list_connections()[0]
with patch('urllib.request.urlopen',
side_effect=__import__('urllib.error', fromlist=['HTTPError']).HTTPError(
url='', code=503, msg='Service Unavailable', hdrs=None, fp=None)):
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertFalse(result['ok'])
self.assertIn('503', result['error'])
def test_push_no_remote_api_url_returns_not_ok(self):
self._add_office()
link = self.mgr.list_connections()[0]
link['remote_api_url'] = None
result = self.mgr._push_permissions_to_remote(link, 'home', 'homepubkey=')
self.assertFalse(result['ok'])
# ── apply_remote_permissions ──────────────────────────────────────────────
def test_apply_remote_permissions_stores_by_pubkey(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules'):
updated = self.mgr.apply_remote_permissions(
'officepubkey=',
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {'calendar': False, 'files': True, 'mail': False, 'webdav': False}},
)
self.assertTrue(updated['permissions']['inbound']['calendar'])
self.assertTrue(updated['permissions']['outbound']['files'])
# Persisted to disk
link = self.mgr.list_connections()[0]
self.assertTrue(link['permissions']['inbound']['calendar'])
self.assertIsNotNone(link['last_remote_update_at'])
def test_apply_remote_permissions_unknown_pubkey_raises(self):
self._add_office()
with self.assertRaises(ValueError):
self.mgr.apply_remote_permissions('nosuchkey=', {})
def test_apply_remote_permissions_calls_apply_cell_rules(self):
self._add_office()
with patch('firewall_manager.apply_cell_rules') as mock_rules:
self.mgr.apply_remote_permissions(
'officepubkey=',
{'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
'outbound': {}},
)
mock_rules.assert_called_once_with('office', '10.1.0.0/24', ['calendar'])
# ── replay_pending_pushes ─────────────────────────────────────────────────
def test_replay_retries_pending_links(self):
self._add_office(push_ok=False) # leaves pending_push=True
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
summary = self.mgr.replay_pending_pushes()
push_mock.assert_called_once()
self.assertEqual(summary['attempted'], 1)
self.assertEqual(summary['ok'], 1)
self.assertFalse(self.mgr.list_connections()[0]['pending_push'])
def test_replay_skips_non_pending_links(self):
self._add_office(push_ok=True) # pending_push=False after success
push_mock = MagicMock(return_value={'ok': True, 'error': None})
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote', push_mock):
summary = self.mgr.replay_pending_pushes()
push_mock.assert_not_called()
self.assertEqual(summary['attempted'], 0)
def test_replay_push_failure_leaves_pending(self):
self._add_office(push_ok=False)
with patch('cell_link_manager.CellLinkManager._local_identity',
return_value={'cell_name': 'home', 'public_key': 'homepubkey='}), \
patch('cell_link_manager.CellLinkManager._push_permissions_to_remote',
return_value={'ok': False, 'error': 'timeout'}):
summary = self.mgr.replay_pending_pushes()
self.assertEqual(summary['failed'], 1)
self.assertTrue(self.mgr.list_connections()[0]['pending_push'])
def test_replay_identity_failure_returns_empty_summary(self):
self._add_office(push_ok=False)
with patch('cell_link_manager.CellLinkManager._local_identity',
side_effect=RuntimeError('no app context')):
summary = self.mgr.replay_pending_pushes()
self.assertEqual(summary['attempted'], 0)
# ── _load migration ───────────────────────────────────────────────────────
def test_load_migration_injects_sync_fields_on_legacy_record(self):
legacy = [{
'cell_name': 'office',
'public_key': 'officepubkey=',
'vpn_subnet': '10.1.0.0/24',
'dns_ip': '10.1.0.1',
'domain': 'office.cell',
'permissions': {'inbound': {}, 'outbound': {}},
}]
links_file = os.path.join(self.test_dir, 'cell_links.json')
with open(links_file, 'w') as f:
json.dump(legacy, f)
links = self.mgr.list_connections()
link = links[0]
self.assertIn('remote_api_url', link)
self.assertIn('pending_push', link)
self.assertIn('last_push_status', link)
self.assertIn('last_push_at', link)
self.assertIn('last_remote_update_at', link)
self.assertEqual(link['remote_api_url'], 'http://10.1.0.1:3000')
self.assertTrue(link['pending_push']) # pre-existing → marked pending
self.assertEqual(link['last_push_status'], 'never')
# Fields persisted to disk after migration
with open(links_file) as f:
raw = json.load(f)
self.assertIn('pending_push', raw[0])
if __name__ == '__main__':
unittest.main()