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
+169
View File
@@ -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()