Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit 0ba8184

Browse files
committed
feat(adr-005): per-object auth demo on DELETE /api/items/{id}
Demonstrates the ADR-005 mandate that #[NoAdminRequired] mutation endpoints MUST carry an explicit backend auth check. The template had no domain mutation endpoint to demo this on, so the audit listed it as 'partial' — this commit adds a minimal, copy-pasteable example. - lib/Service/ItemService::delete($id, $userId) enforces an admin-OR-owner check via IGroupManager::isAdmin() and the OpenRegister @self.owner metadata, returning a STATUS_* enum the controller maps to HTTP codes. - lib/Controller/ItemController::destroy($id) declares #[NoAdminRequired], derives the acting UID from IUserSession (never a request param), and maps STATUS_DELETED→204, STATUS_FORBIDDEN→403, STATUS_NOT_FOUND→404, STATUS_UNAVAILABLE→503, Throwable→generic 500 with server-side log. - appinfo/routes.php registers DELETE /api/items/{id} before the SPA catch-all, per existing route-ordering comments. - Follows OWASP A01: returns 404 (not 403) for non-existent IDs so response codes don't leak existence. - ItemServiceTest (6 methods) and ItemControllerTest (5 methods) lock down all auth branches + the HTTP mapping. Full unit suite now runs 26 tests / 78 assertions, all passing.
1 parent e185a8e commit 0ba8184

5 files changed

Lines changed: 898 additions & 0 deletions

File tree

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'],
2626
['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'],
2727

28+
// Items API — demonstrates ADR-005 #[NoAdminRequired] + per-object auth
29+
// on a mutation (admin OR owner check lives in ItemService::delete()).
30+
['name' => 'item#destroy', 'url' => '/api/items/{id}', 'verb' => 'DELETE'],
31+
2832
// Prometheus metrics endpoint (ADR-006) — admin auth.
2933
['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'],
3034

lib/Controller/ItemController.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/**
4+
* AppTemplate Item Controller
5+
*
6+
* Controller for Article (item) mutation endpoints. Demonstrates the ADR-005
7+
* `#[NoAdminRequired]` + per-object auth pattern on DELETE /api/items/{id}.
8+
*
9+
* @category Controller
10+
* @package OCA\AppTemplate\Controller
11+
*
12+
* @author Conduction Development Team <info@conduction.nl>
13+
* @copyright 2026 Conduction B.V.
14+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
15+
*
16+
* @version GIT: <git-id>
17+
*
18+
* @link https://conduction.nl
19+
*
20+
* @spec openspec/changes/example-change/tasks.md#task-5
21+
*/
22+
23+
declare(strict_types=1);
24+
25+
namespace OCA\AppTemplate\Controller;
26+
27+
use OCA\AppTemplate\AppInfo\Application;
28+
use OCA\AppTemplate\Service\ItemService;
29+
use OCP\AppFramework\Controller;
30+
use OCP\AppFramework\Http;
31+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
32+
use OCP\AppFramework\Http\JSONResponse;
33+
use OCP\AppFramework\Http\Response;
34+
use OCP\IRequest;
35+
use OCP\IUserSession;
36+
use Psr\Log\LoggerInterface;
37+
38+
/**
39+
* Controller for Article (item) mutations.
40+
*
41+
* Demonstrates ADR-003 (Controller → Service layering: the controller only
42+
* maps service results to HTTP codes) and ADR-005 (per-object authorization
43+
* on mutations: `#[NoAdminRequired]` paired with an explicit backend check
44+
* in the service).
45+
*/
46+
class ItemController extends Controller
47+
{
48+
/**
49+
* Constructor for ItemController.
50+
*
51+
* @param IRequest $request The HTTP request
52+
* @param ItemService $itemService The item service (owns auth rules)
53+
* @param IUserSession $userSession The user session (backend-derived UID)
54+
* @param LoggerInterface $logger The logger (server-side error logging)
55+
*
56+
* @return void
57+
*
58+
* @spec openspec/changes/example-change/tasks.md#task-5
59+
*/
60+
public function __construct(
61+
IRequest $request,
62+
private readonly ItemService $itemService,
63+
private readonly IUserSession $userSession,
64+
private readonly LoggerInterface $logger,
65+
) {
66+
parent::__construct(appName: Application::APP_ID, request: $request);
67+
}//end __construct()
68+
69+
/**
70+
* Delete an Article object by UUID.
71+
*
72+
* `#[NoAdminRequired]` lets non-admins reach the endpoint; authorization
73+
* is enforced inside `ItemService::delete()` via an admin-or-owner check
74+
* (ADR-005). The controller ONLY maps the service's STATUS_* result to
75+
* the correct HTTP code:
76+
*
77+
* - 204 No Content — deleted
78+
* - 403 Forbidden — authenticated but not authorized for THIS object
79+
* - 404 Not Found — object does not exist (or register not configured)
80+
* - 503 Service Unavailable — OpenRegister missing
81+
*
82+
* Error bodies use static generic messages (ADR-005) — the real error is
83+
* logged server-side and never leaked to the client.
84+
*
85+
* @param string $id UUID of the Article object to delete
86+
*
87+
* @return Response
88+
*
89+
* @spec openspec/changes/example-change/tasks.md#task-5
90+
*/
91+
#[NoAdminRequired]
92+
public function destroy(string $id): Response
93+
{
94+
$user = $this->userSession->getUser();
95+
if ($user === null) {
96+
return new JSONResponse(['message' => 'Authentication required'], Http::STATUS_UNAUTHORIZED);
97+
}
98+
99+
try {
100+
$status = $this->itemService->delete(id: $id, userId: $user->getUID());
101+
} catch (\Throwable $e) {
102+
$this->logger->error(
103+
'AppTemplate: failed to delete item',
104+
['exception' => $e]
105+
);
106+
return new JSONResponse(['message' => 'Operation failed'], Http::STATUS_INTERNAL_SERVER_ERROR);
107+
}
108+
109+
return match ($status) {
110+
ItemService::STATUS_DELETED => new Response(Http::STATUS_NO_CONTENT),
111+
ItemService::STATUS_FORBIDDEN => new JSONResponse(['message' => 'Forbidden'], Http::STATUS_FORBIDDEN),
112+
ItemService::STATUS_NOT_FOUND => new JSONResponse(['message' => 'Not found'], Http::STATUS_NOT_FOUND),
113+
ItemService::STATUS_UNAVAILABLE => new JSONResponse(['message' => 'Service unavailable'], Http::STATUS_SERVICE_UNAVAILABLE),
114+
default => new JSONResponse(['message' => 'Operation failed'], Http::STATUS_INTERNAL_SERVER_ERROR),
115+
};
116+
}//end destroy()
117+
}//end class

lib/Service/ItemService.php

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
/**
4+
* AppTemplate Item Service
5+
*
6+
* Service for domain-level operations on Article objects, including the ADR-005
7+
* per-object authorization check demonstrated on delete().
8+
*
9+
* @category Service
10+
* @package OCA\AppTemplate\Service
11+
*
12+
* @author Conduction Development Team <info@conduction.nl>
13+
* @copyright 2026 Conduction B.V.
14+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
15+
*
16+
* @version GIT: <git-id>
17+
*
18+
* @link https://conduction.nl
19+
*
20+
* @spec openspec/changes/example-change/tasks.md#task-5
21+
*/
22+
23+
declare(strict_types=1);
24+
25+
namespace OCA\AppTemplate\Service;
26+
27+
use OCA\AppTemplate\AppInfo\Application;
28+
use OCP\App\IAppManager;
29+
use OCP\IAppConfig;
30+
use OCP\IGroupManager;
31+
use Psr\Container\ContainerInterface;
32+
33+
/**
34+
* Service for Article (item) operations.
35+
*
36+
* ## ADR-005 per-object authorization
37+
*
38+
* `delete()` demonstrates the `#[NoAdminRequired]` + per-object auth pattern
39+
* documented in ADR-005 and OWASP A01 (Broken Access Control):
40+
*
41+
* 1. The controller declares `#[NoAdminRequired]` so non-admins can reach it.
42+
* 2. This service performs an **explicit backend check** that the caller is
43+
* either (a) a Nextcloud group admin, or (b) the owner of the object
44+
* (matched via the OpenRegister `@self.owner` metadata).
45+
* 3. On failure the service returns a `Status` enum value the controller maps
46+
* to the right HTTP code (403/404/204) — controllers are thin, services
47+
* own the rules.
48+
*
49+
* The auth check MUST run on the backend, not the frontend, because the
50+
* frontend is untrusted (ADR-005). Returning 403 vs 404 also matters for
51+
* OWASP A01: a plain 403 on non-existent IDs leaks existence, so we return
52+
* 404 for "not found" and 403 only when the object exists but the caller
53+
* cannot touch it.
54+
*/
55+
class ItemService
56+
{
57+
58+
/**
59+
* Result status enum used by delete() so the controller can map to HTTP codes.
60+
*/
61+
public const STATUS_DELETED = 'deleted';
62+
63+
public const STATUS_NOT_FOUND = 'not_found';
64+
65+
public const STATUS_FORBIDDEN = 'forbidden';
66+
67+
public const STATUS_UNAVAILABLE = 'unavailable';
68+
69+
/**
70+
* Constructor for ItemService.
71+
*
72+
* @param IAppConfig $appConfig The app config interface
73+
* @param IAppManager $appManager The app manager (to check OpenRegister presence)
74+
* @param IGroupManager $groupManager The group manager (admin check)
75+
* @param ContainerInterface $container The DI container (lazy-resolves
76+
* OpenRegister's ObjectService so
77+
* the app still loads when
78+
* OpenRegister is absent)
79+
*
80+
* @return void
81+
*
82+
* @spec openspec/changes/example-change/tasks.md#task-5
83+
*/
84+
public function __construct(
85+
private readonly IAppConfig $appConfig,
86+
private readonly IAppManager $appManager,
87+
private readonly IGroupManager $groupManager,
88+
private readonly ContainerInterface $container,
89+
) {
90+
}//end __construct()
91+
92+
/**
93+
* Delete an Article object, enforcing per-object authorization.
94+
*
95+
* Callers must be either a Nextcloud group admin or the OpenRegister
96+
* owner of the object. Returns a STATUS_* constant the controller maps
97+
* to the right HTTP response:
98+
*
99+
* - STATUS_DELETED → 204 No Content
100+
* - STATUS_NOT_FOUND → 404
101+
* - STATUS_FORBIDDEN → 403
102+
* - STATUS_UNAVAILABLE → 503 (OpenRegister missing)
103+
*
104+
* @param string $id UUID of the Article object to delete
105+
* @param string $userId UID of the acting user (always backend-derived,
106+
* NEVER taken from a request parameter)
107+
*
108+
* @return string One of the STATUS_* constants.
109+
*
110+
* @spec openspec/changes/example-change/tasks.md#task-5
111+
*/
112+
public function delete(string $id, string $userId): string
113+
{
114+
if ($this->appManager->isInstalled('openregister') === false) {
115+
return self::STATUS_UNAVAILABLE;
116+
}
117+
118+
$objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService');
119+
120+
$register = $this->appConfig->getValueString(Application::APP_ID, 'register', '');
121+
if ($register === '') {
122+
// Template isn't configured yet — no register to delete from.
123+
return self::STATUS_NOT_FOUND;
124+
}
125+
126+
$object = $objectService->find(register: $register, schema: 'article', id: $id);
127+
if ($object === null) {
128+
// OWASP A01: return 404 for non-existent objects — do NOT 403, which
129+
// would leak existence. Only returned once we know the object is not there.
130+
return self::STATUS_NOT_FOUND;
131+
}
132+
133+
if ($this->isAuthorized(object: $object, userId: $userId) === false) {
134+
return self::STATUS_FORBIDDEN;
135+
}
136+
137+
$objectService->delete(register: $register, schema: 'article', id: $id);
138+
139+
return self::STATUS_DELETED;
140+
}//end delete()
141+
142+
/**
143+
* Check whether the user is allowed to mutate the given object.
144+
*
145+
* Authorization rule: caller is a Nextcloud group admin OR is the
146+
* OpenRegister owner of the object (via `@self.owner`).
147+
*
148+
* @param object|array<string,mixed> $object The object as returned by OpenRegister
149+
* (may be an entity or an associative array)
150+
* @param string $userId The acting user's UID
151+
*
152+
* @return bool True if authorized, false otherwise.
153+
*
154+
* @spec openspec/changes/example-change/tasks.md#task-5
155+
*/
156+
private function isAuthorized(object|array $object, string $userId): bool
157+
{
158+
if ($this->groupManager->isAdmin($userId) === true) {
159+
return true;
160+
}
161+
162+
$owner = $this->extractOwner($object);
163+
return ($owner !== null && $owner === $userId);
164+
}//end isAuthorized()
165+
166+
/**
167+
* Extract the owner UID from an OpenRegister object, tolerating both
168+
* the entity shape (getOwner() / jsonSerialize()['@self']['owner'])
169+
* and the plain associative-array shape.
170+
*
171+
* @param object|array<string,mixed> $object The object to inspect
172+
*
173+
* @return string|null The owner UID, or null if it cannot be determined.
174+
*
175+
* @spec openspec/changes/example-change/tasks.md#task-5
176+
*/
177+
private function extractOwner(object|array $object): ?string
178+
{
179+
if (is_array($object) === true) {
180+
$self = ($object['@self'] ?? []);
181+
return is_array($self) === true ? (($self['owner'] ?? null)) : null;
182+
}
183+
184+
if (method_exists($object, 'getOwner') === true) {
185+
$owner = $object->getOwner();
186+
return is_string($owner) === true ? $owner : null;
187+
}
188+
189+
if (method_exists($object, 'jsonSerialize') === true) {
190+
$serialised = $object->jsonSerialize();
191+
if (is_array($serialised) === true && isset($serialised['@self']['owner']) === true) {
192+
return (string) $serialised['@self']['owner'];
193+
}
194+
}
195+
196+
return null;
197+
}//end extractOwner()
198+
}//end class

0 commit comments

Comments
 (0)