Skip to content

Commit 2826179

Browse files
committed
feat(admin-api): New REST API for administrative operations
Use the new factory framework from horde/core to implement a new REST API for administrative operations. The intended client for this API is hordectl using a 128 character base64 string as preshared bearer token. The API deactivates if the secret is shorter than 51 chars or absent. Currently supports the hordectl builtins: User, Group, Identity, Permission and health checks. All routes were modernized to use RouteBuilder with PSR-7 and comprehensive error handling. Test coverage including security validation and authentication trait tests. No user-facing horde functionality was changed or ported to the new APIs.
1 parent 62579e8 commit 2826179

16 files changed

Lines changed: 3287 additions & 171 deletions

config/conf.xml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@
7171
minutes that links signed with HMACs (to prevent forged URL parametes)
7272
will be valid. Higher values may make your users more vulnerable to
7373
forgery or phishing.">30</configinteger>
74-
<configenum name="pretty" required="false" desc="Use pretty URLs?">false
74+
<configenum name="pretty" required="false" desc="Use pretty URLs? WARNING: Many modern Horde features (Admin API, responsive UI, REST endpoints) require URL rewriting and will break if set to 'No'. For Apache with mod_rewrite: use .htaccess in webroot. For Nginx: configure location blocks with try_files. For PHP built-in server: router script handles rewriting automatically. Setting to 'No' is only recommended for legacy deployments.">false
7575
<values>
76-
<value desc="No (GET-based URLs)">false</value>
77-
<value desc="URL rewriting (mod_rewrite, lighttpd rules, etc.)">rewrite</value>
76+
<value desc="No (GET-based URLs) - Not recommended: breaks modern features">false</value>
77+
<value desc="URL rewriting (Apache mod_rewrite, Nginx, PHP built-in server) - Recommended">rewrite</value>
7878
</values>
7979
</configenum>
8080
</configsection>
@@ -85,6 +85,13 @@
8585
all connections to be safe (e.g. when SSL is handled by an SSL crypto card
8686
and not by the webserver) this value should be '*'."/>
8787

88+
<configheader>Admin API Settings</configheader>
89+
<configsection name="admin_api">
90+
<configsecret name="admin_secret" desc="Cryptographically random secret for administrative API authentication (default length: 128 characters, minimum: 51 characters). Generated automatically by hordectl - do not set manually. Setting to empty string or a string below 51 characters disables admin access until reconfigured. Use 'hordectl secret:generate' to create or 'hordectl secret:rotate' to change. This value grants full administrative access - never share it or commit it to version control." required="false"/>
91+
<configboolean name="enabled" desc="Enable the administrative REST API for hordectl. Requires admin_secret to be set. WARNING: This API provides full administrative control over Horde.">false</configboolean>
92+
<configstring name="path" desc="URL path for administrative API endpoint">admin/api</configstring>
93+
</configsection>
94+
8895
<configheader>Session Settings</configheader>
8996
<configsection name="session">
9097
<configstring name="name" desc="What name should we use for the session

config/routes.php

Lines changed: 232 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,255 @@
11
<?php
2+
23
// ADMINISTRATORS: Do not edit this file. Put custom routes into var/config/horde/routes.local.php and run composer horde:reconfigure
4+
35
namespace Horde\Horde;
6+
47
use Psr\Http\Server\MiddlewareInterface;
58
use Psr\Http\Server\RequestHandlerInterface;
69

710
// Responsive UI Routes - Phase 0: Login
8-
$mapper->connect(
9-
'ResponsiveLogin',
10-
'/auth/login',
11-
[
12-
'controller' => Auth\ResponsiveLoginController::class,
13-
'HordeAuthType' => 'NONE',
14-
'stack' => [\Horde\Core\Middleware\AuthHordeSession::class],
15-
]
16-
);
11+
$mapper->buildRoute(uri: '/auth/login', name: 'ResponsiveLogin')
12+
->withController(Auth\ResponsiveLoginController::class)
13+
->withDefaults(['HordeAuthType' => 'NONE'])
14+
->withMiddleware([\Horde\Core\Middleware\AuthHordeSession::class])
15+
->add();
1716

1817
// Responsive logout - clean endpoint without tokens
19-
$mapper->connect(
20-
'ResponsiveLogout',
21-
'/auth/logout',
22-
[
23-
'controller' => Auth\ResponsiveLogoutController::class,
24-
'HordeAuthType' => 'authenticate', // Must be authenticated to logout
25-
'stack' => [],
26-
]
27-
);
18+
$mapper->buildRoute(uri: '/auth/logout', name: 'ResponsiveLogout')
19+
->withController(Auth\ResponsiveLogoutController::class)
20+
->withDefaults(['HordeAuthType' => 'authenticate']) // Must be authenticated to logout
21+
->add();
2822

2923
// Responsive UI Routes - Phase 1: Portal
30-
$mapper->connect(
31-
'ResponsivePortal',
32-
'/portal/',
33-
[
34-
'controller' => Portal\ResponsivePortalController::class,
35-
'HordeAuthType' => 'authenticate',
36-
'stack' => [],
37-
]
38-
);
24+
$mapper->buildRoute(uri: '/portal/', name: 'ResponsivePortal')
25+
->withController(Portal\ResponsivePortalController::class)
26+
->withDefaults(['HordeAuthType' => 'authenticate'])
27+
->add();
3928

4029
// Smartmobile portal replacement - redirects legacy smartmobile.php to responsive portal
4130
// The legacy jQuery Mobile smartmobile portal has been replaced with the unified
4231
// responsive portal that works on all devices (mobile, tablet, desktop)
43-
$mapper->connect(
44-
'SmartmobilePortalAlias',
45-
'/services/portal/smartmobile.php',
46-
[
47-
'controller' => Portal\ResponsivePortalController::class,
48-
'HordeAuthType' => 'authenticate',
49-
'stack' => [],
50-
]
51-
);
32+
$mapper->buildRoute(uri: '/services/portal/smartmobile.php', name: 'SmartmobilePortalAlias')
33+
->withController(Portal\ResponsivePortalController::class)
34+
->withDefaults(['HordeAuthType' => 'authenticate'])
35+
->add();
5236

5337
// Authentication API Routes
54-
$mapper->connect(
55-
'AuthApiLogin',
56-
'/api/v1/auth/login',
57-
[
58-
'controller' => Auth\AuthApiController::class,
59-
'HordeAuthType' => 'NONE',
60-
'stack' => [\Horde\Http\Server\Middleware\JsonBodyParser::class],
61-
]
62-
);
38+
$mapper->buildRoute(uri: '/api/v1/auth/login', name: 'AuthApiLogin')
39+
->withController(Auth\AuthApiController::class)
40+
->withDefaults(['HordeAuthType' => 'NONE'])
41+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
42+
->add();
6343

6444
// Dual-mode refresh endpoint:
6545
// - With refresh_token: Standard JWT refresh
6646
// - Without refresh_token: Bootstrap JWT from session (for old login.php users)
67-
$mapper->connect(
68-
'AuthApiRefresh',
69-
'/api/v1/auth/refresh',
70-
[
71-
'controller' => Auth\AuthApiController::class,
72-
'HordeAuthType' => 'NONE', // Auth checked inside controller
73-
'stack' => [\Horde\Http\Server\Middleware\JsonBodyParser::class],
74-
]
75-
);
76-
77-
$mapper->connect(
78-
'AuthApiLogout',
79-
'/api/v1/auth/logout',
80-
[
81-
'controller' => Auth\AuthApiController::class,
82-
'HordeAuthType' => 'NONE',
83-
'stack' => [],
84-
]
85-
);
47+
$mapper->buildRoute(uri: '/api/v1/auth/refresh', name: 'AuthApiRefresh')
48+
->withController(Auth\AuthApiController::class)
49+
->withDefaults(['HordeAuthType' => 'NONE']) // Auth checked inside controller
50+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
51+
->add();
52+
53+
$mapper->buildRoute(uri: '/api/v1/auth/logout', name: 'AuthApiLogout')
54+
->withController(Auth\AuthApiController::class)
55+
->withDefaults(['HordeAuthType' => 'NONE'])
56+
->add();
8657

8758
// Preliminary implementation of a readiness check route. Just returns body "1"
88-
$mapper->connect(
89-
'Readiness',
90-
'/observability/readiness',
91-
[
92-
'controller' => Observability\Readiness::class,
93-
// Override this in routes.local.php if you don't want to expose this unautenticated
94-
'HordeAuthType' => 'NONE',
95-
]
96-
);
59+
$mapper->buildRoute(uri: '/observability/readiness', name: 'Readiness')
60+
->withController(Observability\Readiness::class)
61+
// Override this in routes.local.php if you don't want to expose this unautenticated
62+
->withDefaults(['HordeAuthType' => 'NONE'])
63+
->add();
64+
65+
// Admin API Routes - Introspection
66+
$mapper->buildRoute(uri: '/api/v1/admin/info', name: 'AdminApiInfo')
67+
->withController(Admin\AdminApiController::class)
68+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'info'])
69+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
70+
->add();
71+
72+
$mapper->buildRoute(uri: '/api/v1/admin/applications', name: 'AdminApiApplications')
73+
->withController(Admin\AdminApiController::class)
74+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'applications'])
75+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
76+
->add();
77+
78+
// Admin API Routes - Users
79+
$mapper->buildRoute(uri: '/api/v1/admin/users', name: 'AdminApiUserList')
80+
->withController(Admin\UserController::class)
81+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'list'])
82+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
83+
->withMethods(['GET'])
84+
->add();
85+
86+
$mapper->buildRoute(uri: '/api/v1/admin/users', name: 'AdminApiUserCreate')
87+
->withController(Admin\UserController::class)
88+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'create'])
89+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
90+
->withMethods(['POST'])
91+
->add();
92+
93+
$mapper->buildRoute(uri: '/api/v1/admin/users/:username', name: 'AdminApiUser')
94+
->withController(Admin\UserController::class)
95+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'get'])
96+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
97+
->withMethods(['GET'])
98+
->add();
99+
100+
$mapper->buildRoute(uri: '/api/v1/admin/users/:username', name: 'AdminApiUserDelete')
101+
->withController(Admin\UserController::class)
102+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'delete'])
103+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
104+
->withMethods(['DELETE'])
105+
->add();
106+
107+
$mapper->buildRoute(uri: '/api/v1/admin/users/:username/password', name: 'AdminApiUserPassword')
108+
->withController(Admin\UserController::class)
109+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'updatePassword'])
110+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
111+
->add();
112+
113+
// Admin API Routes - Identities (first-class resource)
114+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username', name: 'AdminApiIdentityList')
115+
->withController(Admin\IdentityController::class)
116+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'list'])
117+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
118+
->withMethods(['GET'])
119+
->add();
120+
121+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username', name: 'AdminApiIdentityCreate')
122+
->withController(Admin\IdentityController::class)
123+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'create'])
124+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
125+
->withMethods(['POST'])
126+
->add();
127+
128+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username/:index', name: 'AdminApiIdentityGet')
129+
->withController(Admin\IdentityController::class)
130+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'get'])
131+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
132+
->withMethods(['GET'])
133+
->add();
134+
135+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username/:index', name: 'AdminApiIdentityUpdate')
136+
->withController(Admin\IdentityController::class)
137+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'update'])
138+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
139+
->withMethods(['PUT', 'PATCH'])
140+
->add();
141+
142+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username/:index', name: 'AdminApiIdentityDelete')
143+
->withController(Admin\IdentityController::class)
144+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'delete'])
145+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
146+
->withMethods(['DELETE'])
147+
->add();
148+
149+
$mapper->buildRoute(uri: '/api/v1/admin/identities/:username/:index/default', name: 'AdminApiIdentitySetDefault')
150+
->withController(Admin\IdentityController::class)
151+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'setDefault'])
152+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
153+
->withMethods(['PATCH'])
154+
->add();
155+
156+
// Group Management API Routes
157+
$mapper->buildRoute(uri: '/api/v1/admin/groups', name: 'AdminApiGroupCreate')
158+
->withController(Admin\GroupController::class)
159+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'create'])
160+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
161+
->withMethods(['POST'])
162+
->add();
163+
164+
$mapper->buildRoute(uri: '/api/v1/admin/groups', name: 'AdminApiGroupList')
165+
->withController(Admin\GroupController::class)
166+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'list'])
167+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
168+
->add();
169+
170+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier', name: 'AdminApiGroupDelete')
171+
->withController(Admin\GroupController::class)
172+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'delete'])
173+
->withMethods(['DELETE'])
174+
->add();
175+
176+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier', name: 'AdminApiGroupGet')
177+
->withController(Admin\GroupController::class)
178+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'get'])
179+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
180+
->add();
181+
182+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier/members', name: 'AdminApiGroupMembersAdd')
183+
->withController(Admin\GroupController::class)
184+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'addMember'])
185+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
186+
->withMethods(['POST'])
187+
->add();
188+
189+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier/members', name: 'AdminApiGroupMembersSet')
190+
->withController(Admin\GroupController::class)
191+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'setMembers'])
192+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
193+
->withMethods(['PUT'])
194+
->add();
195+
196+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier/members', name: 'AdminApiGroupMembersList')
197+
->withController(Admin\GroupController::class)
198+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'getMembers'])
199+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
200+
->add();
201+
202+
$mapper->buildRoute(uri: '/api/v1/admin/groups/:identifier/members/:username', name: 'AdminApiGroupMembersRemove')
203+
->withController(Admin\GroupController::class)
204+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'removeMember'])
205+
->withMethods(['DELETE'])
206+
->add();
207+
208+
// Permission Management API Routes
209+
$mapper->buildRoute(uri: '/api/v1/admin/permissions', name: 'AdminApiPermissionList')
210+
->withController(Admin\PermissionController::class)
211+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'list'])
212+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
213+
->withMethods(['GET'])
214+
->add();
215+
216+
$mapper->buildRoute(uri: '/api/v1/admin/permissions', name: 'AdminApiPermissionCreate')
217+
->withController(Admin\PermissionController::class)
218+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'create'])
219+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
220+
->withMethods(['POST'])
221+
->add();
222+
223+
$mapper->buildRoute(uri: '/api/v1/admin/permissions/:name', name: 'AdminApiPermissionGet')
224+
->withController(Admin\PermissionController::class)
225+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'get'])
226+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
227+
->withMethods(['GET'])
228+
->add();
229+
230+
$mapper->buildRoute(uri: '/api/v1/admin/permissions/:name', name: 'AdminApiPermissionUpdate')
231+
->withController(Admin\PermissionController::class)
232+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'update'])
233+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
234+
->withMethods(['PATCH'])
235+
->add();
236+
237+
$mapper->buildRoute(uri: '/api/v1/admin/permissions/:name', name: 'AdminApiPermissionDelete')
238+
->withController(Admin\PermissionController::class)
239+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'delete'])
240+
->withMethods(['DELETE'])
241+
->add();
242+
243+
$mapper->buildRoute(uri: '/api/v1/admin/permissions/:name/parents', name: 'AdminApiPermissionGetParents')
244+
->withController(Admin\PermissionController::class)
245+
->withDefaults(['HordeAuthType' => 'NONE', 'action' => 'getParents'])
246+
->withMiddleware([\Horde\Http\Server\Middleware\JsonBodyParser::class])
247+
->withMethods(['GET'])
248+
->add();
249+
250+
// Health Check API Routes
251+
$mapper->buildRoute(uri: '/api/v1/admin/health/:subsystem', name: 'AdminApiHealthCheck')
252+
->withController(Admin\HealthCheckController::class)
253+
->withDefaults(['HordeAuthType' => 'NONE', 'subsystem' => 'all'])
254+
->withMethods(['GET'])
255+
->add();

0 commit comments

Comments
 (0)