Skip to content

Commit c02d363

Browse files
committed
Add MultiUserXandikosBackend for automatic principal creation
Implement MultiUserXandikosBackend that extends XandikosBackend to automatically create principals for authenticated users. This backend supports configurable principal path formats and integrates with X-Remote-User authentication headers in both WSGI and aiohttp modes. Key features: - Auto-creates principals with default calendar and addressbook - Configurable principal path prefix/suffix (e.g., "/users/{user}/principal/") - Seamless integration with reverse proxy authentication - Comprehensive test coverage for all functionality
1 parent 5ed1802 commit c02d363

File tree

4 files changed

+360
-2
lines changed

4 files changed

+360
-2
lines changed

xandikos/tests/test_auth.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# Xandikos
2+
# Copyright (C) 2016-2017 Jelmer Vernooij <[email protected]>, et al.
3+
#
4+
# This program is free software; you can redistribute it and/or
5+
# modify it under the terms of the GNU General Public License
6+
# as published by the Free Software Foundation; version 3
7+
# of the License or (at your option) any later version of
8+
# the License.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program; if not, write to the Free Software
17+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18+
# MA 02110-1301, USA.
19+
20+
"""Tests for authentication handling in xandikos."""
21+
22+
import asyncio
23+
import tempfile
24+
import shutil
25+
import unittest
26+
from unittest.mock import MagicMock, AsyncMock
27+
from wsgiref.util import setup_testing_defaults
28+
29+
from xandikos.webdav import WebDAVApp
30+
from xandikos.web import MultiUserXandikosBackend
31+
32+
33+
class MockBackend:
34+
"""Mock backend for testing."""
35+
36+
def __init__(self):
37+
self.set_principal_calls = []
38+
self.resources = {}
39+
40+
def set_principal(self, user):
41+
self.set_principal_calls.append(user)
42+
43+
def get_resource(self, path):
44+
return self.resources.get(path)
45+
46+
47+
class AuthenticationTests(unittest.TestCase):
48+
"""Tests for authentication header handling."""
49+
50+
def setUp(self):
51+
self.backend = MockBackend()
52+
self.app = WebDAVApp(self.backend)
53+
self.loop = asyncio.new_event_loop()
54+
asyncio.set_event_loop(self.loop)
55+
56+
def tearDown(self):
57+
self.loop.close()
58+
59+
def test_wsgi_x_remote_user_header(self):
60+
"""Test that HTTP_X_REMOTE_USER is handled in WSGI."""
61+
environ = {
62+
"REQUEST_METHOD": "OPTIONS",
63+
"PATH_INFO": "/",
64+
"HTTP_X_REMOTE_USER": "testuser",
65+
}
66+
setup_testing_defaults(environ)
67+
68+
# Mock the resource
69+
mock_resource = MagicMock()
70+
mock_resource.resource_types = []
71+
self.backend.resources["/"] = mock_resource
72+
73+
# Mock start_response
74+
responses = []
75+
76+
def start_response(status, headers):
77+
responses.append((status, headers))
78+
return lambda x: None
79+
80+
# Call the WSGI handler
81+
list(self.app.handle_wsgi_request(environ, start_response))
82+
83+
# Check that we got a response
84+
self.assertTrue(len(responses) > 0)
85+
86+
# Check that set_principal was called with the user
87+
self.assertEqual(["testuser"], self.backend.set_principal_calls)
88+
89+
# Check that REMOTE_USER was set in environ for the request
90+
# (The environ is recreated in handle_wsgi_request, so we can't check it directly)
91+
92+
def test_wsgi_no_remote_user(self):
93+
"""Test WSGI without authentication header."""
94+
environ = {
95+
"REQUEST_METHOD": "OPTIONS",
96+
"PATH_INFO": "/",
97+
}
98+
setup_testing_defaults(environ)
99+
100+
# Mock the resource
101+
mock_resource = MagicMock()
102+
mock_resource.resource_types = []
103+
self.backend.resources["/"] = mock_resource
104+
105+
# Mock start_response
106+
responses = []
107+
108+
def start_response(status, headers):
109+
responses.append((status, headers))
110+
return lambda x: None
111+
112+
# Call the WSGI handler
113+
self.app.handle_wsgi_request(environ, start_response)
114+
115+
# Check that set_principal was NOT called
116+
self.assertEqual([], self.backend.set_principal_calls)
117+
118+
def test_aiohttp_x_remote_user_header(self):
119+
"""Test that X-Remote-User header is handled in aiohttp."""
120+
# Create a mock aiohttp request
121+
mock_request = AsyncMock()
122+
mock_headers = MagicMock()
123+
mock_headers.get.side_effect = (
124+
lambda k, d=None: "aiohttpuser" if k == "X-Remote-User" else d
125+
)
126+
mock_headers.__getitem__.side_effect = (
127+
lambda k: "aiohttpuser" if k == "X-Remote-User" else None
128+
)
129+
mock_request.headers = mock_headers
130+
mock_request.method = "OPTIONS"
131+
mock_request.path = "/"
132+
mock_request.url = "http://example.com/"
133+
mock_request.raw_path = "/"
134+
mock_request.match_info = {"path_info": "/"}
135+
mock_request.content_type = "text/plain"
136+
mock_request.content_length = 0
137+
mock_request.can_read_body = False
138+
139+
# Mock the resource
140+
mock_resource = MagicMock()
141+
mock_resource.resource_types = []
142+
self.backend.resources["/"] = mock_resource
143+
144+
# Call the aiohttp handler
145+
self.loop.run_until_complete(
146+
self.app.aiohttp_handler(mock_request, "/")
147+
)
148+
149+
# Check that set_principal was called with the user
150+
self.assertEqual(["aiohttpuser"], self.backend.set_principal_calls)
151+
152+
def test_aiohttp_no_remote_user(self):
153+
"""Test aiohttp without authentication header."""
154+
# Create a mock aiohttp request
155+
mock_request = AsyncMock()
156+
mock_headers = MagicMock()
157+
mock_headers.get.return_value = None
158+
mock_request.headers = mock_headers
159+
mock_request.method = "OPTIONS"
160+
mock_request.path = "/"
161+
mock_request.url = "http://example.com/"
162+
mock_request.raw_path = "/"
163+
mock_request.match_info = {"path_info": "/"}
164+
mock_request.content_type = "text/plain"
165+
mock_request.content_length = 0
166+
mock_request.can_read_body = False
167+
168+
# Mock the resource
169+
mock_resource = MagicMock()
170+
mock_resource.resource_types = []
171+
self.backend.resources["/"] = mock_resource
172+
173+
# Call the aiohttp handler
174+
self.loop.run_until_complete(
175+
self.app.aiohttp_handler(mock_request, "/")
176+
)
177+
178+
# Check that set_principal was NOT called
179+
self.assertEqual([], self.backend.set_principal_calls)
180+
181+
182+
class IntegrationTests(unittest.TestCase):
183+
"""Integration tests with real backends."""
184+
185+
def setUp(self):
186+
self.d = tempfile.mkdtemp()
187+
self.loop = asyncio.new_event_loop()
188+
asyncio.set_event_loop(self.loop)
189+
190+
def tearDown(self):
191+
shutil.rmtree(self.d)
192+
self.loop.close()
193+
194+
def test_multiuser_backend_with_aiohttp_auth(self):
195+
"""Test MultiUserXandikosBackend with aiohttp authentication."""
196+
backend = MultiUserXandikosBackend(self.d)
197+
app = WebDAVApp(backend)
198+
199+
# Create a mock aiohttp request with auth
200+
mock_request = AsyncMock()
201+
mock_headers = MagicMock()
202+
mock_headers.get.side_effect = (
203+
lambda k, d=None: "alice" if k == "X-Remote-User" else d
204+
)
205+
mock_headers.__getitem__.side_effect = (
206+
lambda k: "alice" if k == "X-Remote-User" else None
207+
)
208+
mock_request.headers = mock_headers
209+
mock_request.method = "PROPFIND"
210+
mock_request.path = "/alice/"
211+
mock_request.url = "http://example.com/alice/"
212+
mock_request.raw_path = "/alice/"
213+
mock_request.match_info = {"path_info": "/alice/"}
214+
mock_request.content_type = "application/xml"
215+
mock_request.content_length = 0
216+
mock_request.can_read_body = False
217+
218+
# Call the aiohttp handler
219+
self.loop.run_until_complete(app.aiohttp_handler(mock_request, "/"))
220+
221+
# Check that the principal was created
222+
resource = backend.get_resource("/alice/")
223+
self.assertIsNotNone(resource)
224+
# _mark_as_principal normalizes the path, removing trailing slashes
225+
self.assertIn("/alice", backend._user_principals)

xandikos/tests/test_web.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .. import caldav
2828
from ..icalendar import ICalendarFile
2929
from ..store.git import TreeGitStore
30-
from ..web import CalendarCollection, XandikosBackend
30+
from ..web import CalendarCollection, XandikosBackend, MultiUserXandikosBackend
3131

3232
EXAMPLE_VCALENDAR1 = b"""\
3333
BEGIN:VCALENDAR
@@ -175,3 +175,92 @@ def start_response(code, _headers):
175175

176176
self.assertEqual(["200 OK"], codes)
177177
self.assertEqual(b"".join([commit_hash, b"\t", default_branch, b"\n"]), body)
178+
179+
180+
class MultiUserXandikosBackendTests(unittest.TestCase):
181+
"""Tests for MultiUserXandikosBackend."""
182+
183+
def setUp(self):
184+
self.d = tempfile.mkdtemp()
185+
186+
def tearDown(self):
187+
shutil.rmtree(self.d)
188+
189+
def test_create_principal_default_paths(self):
190+
"""Test creating a principal with default path format."""
191+
backend = MultiUserXandikosBackend(self.d)
192+
backend.set_principal("testuser")
193+
194+
# Check that principal was created with default format
195+
principal_path = "/testuser/"
196+
resource = backend.get_resource(principal_path)
197+
self.assertIsNotNone(resource)
198+
# _mark_as_principal normalizes the path, removing trailing slashes
199+
self.assertIn("/testuser", backend._user_principals)
200+
201+
# Check autocreate is enabled
202+
self.assertTrue(backend.autocreate)
203+
204+
def test_create_principal_custom_paths(self):
205+
"""Test creating a principal with custom path format."""
206+
backend = MultiUserXandikosBackend(
207+
self.d, principal_path_prefix="/users/", principal_path_suffix="/principal/"
208+
)
209+
backend.set_principal("customuser")
210+
211+
# Check that principal was created with custom format
212+
principal_path = "/users/customuser/principal/"
213+
resource = backend.get_resource(principal_path)
214+
self.assertIsNotNone(resource)
215+
# _mark_as_principal normalizes the path, removing trailing slashes
216+
self.assertIn("/users/customuser/principal", backend._user_principals)
217+
218+
def test_create_principal_override_paths(self):
219+
"""Test overriding path format at method level."""
220+
backend = MultiUserXandikosBackend(self.d)
221+
backend.set_principal(
222+
"override",
223+
principal_path_prefix="/special/",
224+
principal_path_suffix="/user/",
225+
)
226+
227+
# Check that principal was created with overridden format
228+
principal_path = "/special/override/user/"
229+
resource = backend.get_resource(principal_path)
230+
self.assertIsNotNone(resource)
231+
# _mark_as_principal normalizes the path, removing trailing slashes
232+
self.assertIn("/special/override/user", backend._user_principals)
233+
234+
def test_multiple_users(self):
235+
"""Test creating multiple users."""
236+
backend = MultiUserXandikosBackend(self.d)
237+
238+
# Create multiple users
239+
users = ["alice", "bob", "charlie"]
240+
for user in users:
241+
backend.set_principal(user)
242+
243+
# Verify all users were created
244+
for user in users:
245+
principal_path = f"/{user}/"
246+
resource = backend.get_resource(principal_path)
247+
self.assertIsNotNone(resource)
248+
# _mark_as_principal normalizes the path, removing trailing slashes
249+
self.assertIn(f"/{user}", backend._user_principals)
250+
251+
def test_idempotent_principal_creation(self):
252+
"""Test that setting the same principal twice is idempotent."""
253+
backend = MultiUserXandikosBackend(self.d)
254+
255+
# Create principal first time
256+
backend.set_principal("testuser")
257+
principal_path = "/testuser/"
258+
resource1 = backend.get_resource(principal_path)
259+
260+
# Create same principal again
261+
backend.set_principal("testuser")
262+
resource2 = backend.get_resource(principal_path)
263+
264+
# Should be the same resource
265+
self.assertIsNotNone(resource1)
266+
self.assertIsNotNone(resource2)

xandikos/web.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1059,12 +1059,18 @@ def open_store_from_path(path: str, **kwargs):
10591059

10601060
class XandikosBackend(webdav.Backend):
10611061
def __init__(
1062-
self, path, *, paranoid: bool = False, index_threshold: Optional[int] = None
1062+
self,
1063+
path,
1064+
*,
1065+
paranoid: bool = False,
1066+
index_threshold: Optional[int] = None,
1067+
autocreate: bool = False,
10631068
) -> None:
10641069
self.path = path
10651070
self._user_principals: set[str] = set()
10661071
self.paranoid = paranoid
10671072
self.index_threshold = index_threshold
1073+
self.autocreate = autocreate
10681074

10691075
def _map_to_file_path(self, relpath):
10701076
return os.path.join(self.path, relpath.lstrip("/"))
@@ -1188,6 +1194,30 @@ async def move_collection(
11881194
shutil.move(source_file_path, dest_file_path)
11891195

11901196

1197+
class MultiUserXandikosBackend(XandikosBackend):
1198+
"""Backend that automatically creates principals for authenticated users."""
1199+
1200+
def __init__(self, path, principal_path_prefix="/", principal_path_suffix="/"):
1201+
super().__init__(path, autocreate=True)
1202+
self.principal_path_prefix = principal_path_prefix
1203+
self.principal_path_suffix = principal_path_suffix
1204+
1205+
def set_principal(
1206+
self, user, principal_path_prefix=None, principal_path_suffix=None
1207+
):
1208+
"""Set the principal for a user, creating it if necessary."""
1209+
if principal_path_prefix is None:
1210+
principal_path_prefix = self.principal_path_prefix
1211+
if principal_path_suffix is None:
1212+
principal_path_suffix = self.principal_path_suffix
1213+
1214+
principal = principal_path_prefix + user + principal_path_suffix
1215+
1216+
if not self.get_resource(principal):
1217+
self.create_principal(principal, create_defaults=True)
1218+
self._mark_as_principal(principal)
1219+
1220+
11911221
class XandikosApp(webdav.WebDAVApp):
11921222
"""A wsgi App that provides a Xandikos web server."""
11931223

xandikos/webdav.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2656,6 +2656,20 @@ def _get_allowed_methods(self, request):
26562656
return ret
26572657

26582658
async def _handle_request(self, request, environ, start_response=None):
2659+
# Handle remote user authentication
2660+
remote_user = None
2661+
if hasattr(request, "headers"):
2662+
# aiohttp request
2663+
remote_user = request.headers.get("X-Remote-User")
2664+
2665+
if "ORIGINAL_ENVIRON" in environ:
2666+
# WSGI request
2667+
remote_user = environ["ORIGINAL_ENVIRON"].get("HTTP_X_REMOTE_USER")
2668+
2669+
if remote_user and hasattr(self.backend, "set_principal"):
2670+
environ["REMOTE_USER"] = remote_user
2671+
self.backend.set_principal(remote_user)
2672+
26592673
try:
26602674
do = self.methods[request.method]
26612675
except KeyError:

0 commit comments

Comments
 (0)