Skip to content

feat(integration-registry): routes + controllers + OCS capability (umbrella PR 4/N)#1473

Merged
rubenvdlinde merged 2 commits into
developmentfrom
feature/1307/routes-controller-capabilities
May 18, 2026
Merged

feat(integration-registry): routes + controllers + OCS capability (umbrella PR 4/N)#1473
rubenvdlinde merged 2 commits into
developmentfrom
feature/1307/routes-controller-capabilities

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

Stacked on #1469. Tasks 18-22 of #1307 — Backend — Routes, Controller, Capabilities.

Task What
4.1 IntegrationsControllerGET /api/integrations (with group + enabled filters) + GET /api/integrations/{id}. Role-redacted descriptor per AD-17 (public surface for everyone; admin-only fields omitted for non-admins, not null-stubbed).
4.2 Object-scoped sub-resource dispatch — new dedicated ObjectIntegrationsController owns /api/objects/{register}/{schema}/{id}/integrations/{integrationId}[/{entityId}] (GET / POST / PUT / DELETE). Additive — ObjectsController (2400+ lines) untouched. Error translation: NotImplementedException → 501 with QueryTimeContract envelope; ProviderUnavailableException → 503 with details.cause payload (AD-23); unknown id → 404; other → 500 (real exception logged, generic message returned per ADR-005).
4.3 7 new routes in appinfo/routes.php — 2 discovery + 5 sub-resource.
4.4 IntegrationsCapability — surfaces the registry through /ocs/v2.php/cloud/capabilities, role-redacted per AD-17. Lives in lib/Capabilities/ mirroring UrnCapability (the canonical pattern in this codebase).
4.5 Registered via $context->registerCapability() in Application::register() — no appinfo/info.xml change (OR registers capabilities in code, not XML).

Net new files

  • lib/Controller/IntegrationsController.php
  • lib/Controller/ObjectIntegrationsController.php
  • lib/Capabilities/IntegrationsCapability.php
  • tests/Unit/Controller/ObjectIntegrationsControllerTest.php

Modified

  • appinfo/routes.php — 7 new routes
  • lib/AppInfo/Application.php — DI bindings for both controllers + the capability; registerCapability() call
  • openspec/changes/pluggable-integration-registry/tasks.md + plan.json

Tests

  • 52 tests, 95 assertions — all green inside the openregister-postgres dev container
  • 7 new for ObjectIntegrationsController (dispatch + error translation); the 45 from PRs 1-3 still pass

Spec deviations (flagged inline in tasks.md / plan.json)

  1. Sub-resource dispatch is a NEW controller (ObjectIntegrationsController) rather than a refactor of ObjectsController. Additive, zero regression risk on existing routes. The legacy /api/objects/{...}/files, /api/objects/{...}/notes etc. keep working unchanged.
  2. Capability advertisement uses one ICapability class (IntegrationsCapability) registered via registerCapability(), mirroring UrnCapability. Spec called for editing lib/Service/CapabilitiesService.php — OR doesn't have that file as the canonical integration point.
  3. info.xml capability section unchanged — OR registers capabilities in code, not XML.

Cumulative progress on the umbrella

PR Tasks Status
#1467 1.1–1.6 (foundation)
#1468 2.1–2.5 (schema validator)
#1469 3.1–3.6 (built-in providers)
#this 4.1–4.5 (routes + controllers + capability)
next Admin UI for auth tasks 5.1–5.3
next Frontend JS registry + 3 widgets + Cn* refactor tasks 23-46
then CI parity + scaffold + ADR + translations + acceptance tasks 47-71

22/69 umbrella tasks done after this PR merges.

Stacking note

Base is feature/1307/builtin-providers (PR #1469), which is stacked on feature/1307/schema-validator-refactor (PR #1468), which is stacked on feature/1307/pluggable-integration-registry (PR #1467). When the earlier PRs merge into development, GitHub retargets this PR to development automatically.

🤖 Generated with Claude Code

…brella PR 4/N)

Fourth slice of #1307 — completes tasks 18-22 of the umbrella (Backend
— Routes, Controller, Capabilities). Stacked on PR #1469 (built-in
providers), itself stacked on #1468 (schema validator) and #1467
(foundation).

Tasks completed in this slice (5/69 → cumulative 22/69):

- 4.1 IntegrationsController — read-only API over the registry.
  GET /api/integrations (with `group` and `enabled` filter params),
  GET /api/integrations/{id}. Role-redacted descriptor per AD-17 —
  every authed user sees public fields (id, label, icon, group,
  enabled, storageStrategy, surfaces); admins additionally get
  requiresPermission, openConnectorSource, authStatus. Non-admin
  fields are omitted (not null-stubbed) so absence matches
  non-existence.

- 4.2 Object-scoped sub-resource dispatch via the registry — new
  dedicated ObjectIntegrationsController owns
  /api/objects/{register}/{schema}/{id}/integrations/{integrationId}[/{entityId}]
  (GET / POST / PUT / DELETE). Additive — ObjectsController (2400+
  lines) stays untouched. Error translation per AD-22 / AD-23:
    NotImplementedException → 501 with QueryTimeContract envelope
    ProviderUnavailableException → 503 with details.cause payload
    unknown integration id → 404
    other Throwable → 500 (real exception logged, generic message
    returned per ADR-005).

- 4.3 Routes wired in appinfo/routes.php — both the discovery API
  and the object sub-resource dispatch.

- 4.4 IntegrationsCapability — surfaces the registry through
  /ocs/v2.php/cloud/capabilities, role-redacted per AD-17. Spec said
  'Update lib/Service/CapabilitiesService.php'; OR's capability
  pattern uses one ICapability class per concern (see UrnCapability),
  so the new file lives in lib/Capabilities/. Same end shape,
  idiomatic structure.

- 4.5 Registered via $context->registerCapability() in
  Application::register() — mirrors the existing UrnCapability
  pattern. No appinfo/info.xml change needed (OR doesn't carry
  capability declarations there).

Net new files:
- lib/Controller/IntegrationsController.php
- lib/Controller/ObjectIntegrationsController.php
- lib/Capabilities/IntegrationsCapability.php
- tests/Unit/Controller/ObjectIntegrationsControllerTest.php

Modified:
- appinfo/routes.php — 7 new routes (2 discovery + 5 sub-resource)
- lib/AppInfo/Application.php — DI bindings for the new controllers
  and the capability; registerCapability() call
- openspec/changes/pluggable-integration-registry/tasks.md
- openspec/changes/pluggable-integration-registry/plan.json

Unit tests:
- 52 tests, 95 assertions — all green inside the openregister-postgres
  dev container (7 new for ObjectIntegrationsController dispatch +
  error translation; the 45 from PRs 1-3 still pass)

Spec deviations flagged inline:
1. Sub-resource dispatch lives in a NEW ObjectIntegrationsController
   rather than refactoring the 2400-line ObjectsController. Additive,
   zero regression risk on existing routes.
2. Capability advertisement uses one ICapability class
   (IntegrationsCapability) registered via registerCapability(),
   mirroring UrnCapability. Spec called for editing
   CapabilitiesService — OR doesn't have that file as the canonical
   integration point; the per-concern ICapability pattern is
   idiomatic for this codebase.
3. info.xml capability section unchanged — OR registers capabilities
   in code, not XML.

Refs: #1307
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] SPDX license header missing in new lib/ files (Gate 1)

IntegrationsController.php and ObjectIntegrationsController.php both lack standalone // SPDX-License-Identifier: and // SPDX-FileCopyrightText: comment lines required by hydra-gate-spdx. PHPDoc @license tags are not a substitute. Add the SPDX comment lines at the top of each file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Capability served to unauthenticated callers — provider list leaks to the public

ICapability::getCapabilities() is called for every /ocs/v2.php/cloud/capabilities request, including anonymous ones. currentUserIsAdmin() correctly returns false for anonymous, but the full provider list (id, label, group, enabled, storageStrategy, surfaces) is still returned to an anonymous caller. Either return an empty providers array when the user is not authenticated, or implement IPublicCapability as an explicit opt-in and document it. Opt-in is safer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] resolveProvider leaks full registered-provider list in 404 message

When an unknown integrationId is requested, resolveProvider() throws NotImplementedException with message "Integration 'X' is not registered. Registered: files, tags, xwiki, …". This is returned verbatim in the 404 body. Any authenticated user can enumerate registered integration IDs by probing. Strip the hint or replace with a static message.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No rate limiting on discovery endpoints

show() calls describe($provider, $isAdmin) which for admin callers invokes $provider->health() — a potentially expensive network call to an upstream service. There is no #[UserRateThrottle] or #[AnonRateThrottle]. Add rate-limit attributes to both index() and show().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No test for unauthenticated or cross-user IDOR scenario

The test suite covers happy path, 404, 501, 503, but contains zero tests for: (1) unauthenticated request, (2) authenticated user not owning the object. Adding these tests would immediately expose the missing ownership guard.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Throwable silently swallowed in describe() — no log on health failure

When $provider->health() throws, the catch block sets a static authStatus payload but does not log the exception. A repeatedly failing health check is invisible in server logs. Log at warning level: $this->logger->warning('[IntegrationsController] health() threw for provider …', ['exception' => $e]).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] collectPayload() passes raw request params to providers

collectPayload() calls $this->request->getParams() which returns all parameters including framework-injected keys (e.g. _route). The method removes only the five explicitly named path params. Any other framework-injected key is passed to $provider->create/update. Use $this->request->getPostParams() (body-only) or apply an allowlist/denylist that strips underscore-prefixed internal keys.

Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (4)

  • Missing #[NoCSRFRequired] on public API routes (lib/Controller/IntegrationsController.php:327)
    Both index() and show() are registered in appinfo/routes.php as standard (non-OCS) routes and carry only #[NoAdminRequired]. Without #[NoCSRFRequired], every non-browser REST client (curl, mobile app, OpenConnector) hitting GET /api/integrations or GET /api/integrations/{id} will get a CSRF token rejection. Sibling controllers (RegistersController, SchemasController) all declare #[NoCSRFRequired] alongside #[NoAdminRequired] on their index/show. Add #[NoCSRFRequired].
  • Missing #[NoCSRFRequired] on all five ObjectIntegrations routes — including POST/PUT/DELETE (lib/Controller/ObjectIntegrationsController.php:550)
    All five methods (index, show, create, update, destroy) have only #[NoAdminRequired]. For the read methods this is the same issue as IntegrationsController, but for create (POST), update (PUT), destroy (DELETE), machine-to-machine callers without a CSRF token will be rejected with 401. This breaks the stated use-case of the sub-resource dispatch API.
  • No per-object ownership / permission check — IDOR on all five endpoints (Gate 8) (lib/Controller/ObjectIntegrationsController.php:550)
    Every method is #[NoAdminRequired] with no guard in the method body. Any authenticated user can call GET /api/objects/{register}/{schema}/{id}/integrations/{integrationId} where {id} belongs to another user and receive linked data (files, tags, audit trail, …). The object identified by {register}/{schema}/{id} is never checked for read/write permission before being passed to $provider->list/get/create/update/delete. Mutating operations have no write-permission check at all. Add a per-object ACL check (or delegate to existing ObjectsController auth).
  • SPDX license header missing in new lib/ files (Gate 1)lib/Controller/IntegrationsController.php:1

🟡 Concerns (6)

🟢 Minor (1)

  • objectIntegrations index/create share URL pattern — verify verb-based dispatch (appinfo/routes.php:16)
    objectIntegrations#index (GET) and objectIntegrations#create (POST) both resolve to /api/objects/{register}/{schema}/{id}/integrations/{integrationId}. NC's router differentiates on HTTP verb, but pre-NC 27 had a known dedup issue. A comment confirming verb-based dispatch is tested would help.

Reviewed by WilcoLouwerse via automated batch review.

Base automatically changed from feature/1307/builtin-providers to development May 18, 2026 11:19
@rubenvdlinde rubenvdlinde merged commit c6b8433 into development May 18, 2026
16 of 19 checks passed
@rubenvdlinde rubenvdlinde deleted the feature/1307/routes-controller-capabilities branch May 18, 2026 11:19
@WilcoLouwerse
Copy link
Copy Markdown
Contributor

🔍 Retrospective audit findings — surfaced from #1515

While reviewing integration rollup PR #1515, two findings trace back to the routes/controllers in this PR:

  1. 🔴 Broad IDOR on ObjectIntegrationsController — every method (index/show/create/update/destroy, lines 97-187) is #[NoAdminRequired] and dispatches straight into resolveProvider() with caller-supplied $register, $schema, $id, $entityId, with no per-object authorization check. Combined with the per-provider RBAC gaps in feat(integration-registry): 5 built-in IntegrationProvider implementations (umbrella PR 3/N) #1469, this exposes objects + entities to any authenticated user. Inline comment on #1515
  2. 🟡 resolveProvider() reflects the full registered integration list in 404 bodies — combined with the IntegrationsCapability leak (feat(integration-registry): contract + registry + external router (umbrella PR 1/N) #1467), this is a second redundant disclosure path of internal topology to any authenticated user. Inline comment on #1515

The PR body of #1515 lists finding 2 under "#1473 concerns (not flagged as blockers by reviewer)" but did not surface the broader IDOR pattern. Worth a fixup PR adding PermissionHandler::canRead/canWrite checks before resolveProvider().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants