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:
@@ -516,5 +516,174 @@ class TestUpdateCellPermissions(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPeerSyncPermissionsEndpoint(unittest.TestCase):
|
||||
"""POST /api/cells/peer-sync/permissions — machine-to-machine permission sync."""
|
||||
|
||||
_KNOWN_LINK = {
|
||||
'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': {}},
|
||||
'pending_push': False,
|
||||
'remote_api_url': 'http://10.1.0.1:3000',
|
||||
}
|
||||
|
||||
_VALID_BODY = {
|
||||
'version': 1,
|
||||
'from_cell': 'office',
|
||||
'from_public_key': 'officepubkey=',
|
||||
'permissions': {
|
||||
'inbound': {'calendar': True, 'files': False, 'mail': False, 'webdav': False},
|
||||
'outbound': {'calendar': False, 'files': False, 'mail': False, 'webdav': False},
|
||||
},
|
||||
'sent_at': '2026-05-01T00:00:00Z',
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_valid_source_ip_returns_200(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = json.loads(r.data)
|
||||
self.assertTrue(data.get('ok'))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_valid_source_calls_apply_remote_permissions(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||
self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
mock_clm.apply_remote_permissions.assert_called_once_with(
|
||||
'officepubkey=', self._VALID_BODY['permissions']
|
||||
)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_unknown_source_ip_returns_403(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.9.9.9'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
mock_clm.apply_remote_permissions.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_pubkey_mismatch_returns_403(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
body = dict(self._VALID_BODY, from_public_key='wrongkey=')
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
mock_clm.apply_remote_permissions.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_xff_header_used_for_source_ip(self, mock_clm):
|
||||
"""Caddy appends source IP as last X-Forwarded-For entry."""
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
mock_clm.apply_remote_permissions.return_value = self._KNOWN_LINK
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '172.20.0.5'}, # docker bridge — not in cell subnet
|
||||
headers={'X-Forwarded-For': '192.168.1.1, 10.1.0.5'}, # last entry is real source
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_missing_version_returns_400(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
body = {k: v for k, v in self._VALID_BODY.items() if k != 'version'}
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_wrong_version_returns_400(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
body = dict(self._VALID_BODY, version=99)
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_unknown_service_name_returns_400(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
body = dict(self._VALID_BODY)
|
||||
body['permissions'] = {'inbound': {'hacked': True}, 'outbound': {}}
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(body),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
mock_clm.apply_remote_permissions.assert_not_called()
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_no_body_returns_400(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_apply_remote_permissions_exception_returns_500(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
mock_clm.apply_remote_permissions.side_effect = IOError('disk full')
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 500)
|
||||
self.assertIn('error', json.loads(r.data))
|
||||
|
||||
@patch('app.cell_link_manager')
|
||||
def test_value_error_from_apply_returns_404(self, mock_clm):
|
||||
mock_clm.list_connections.return_value = [self._KNOWN_LINK]
|
||||
mock_clm.apply_remote_permissions.side_effect = ValueError('no link')
|
||||
r = self.client.post(
|
||||
'/api/cells/peer-sync/permissions',
|
||||
data=json.dumps(self._VALID_BODY),
|
||||
content_type='application/json',
|
||||
environ_base={'REMOTE_ADDR': '10.1.0.5'},
|
||||
)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user