Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ jobs:
enable-frontend: true
enable-eslint: true
enable-phpunit: true
# Newman disabled (openbuilt#11): the collections need the `hello-world`
# seed app + the `openbuilt`/`exportJob`/`application` registers
# provisioned. `SeedHelloWorld` swallows exceptions, so when the
# seed-against-OR path fails in CI the app simply isn't there and every
# API call 404s (then the prerequest scripts JSONError on the 404 body).
# Re-running the repair steps (disable/enable) didn't help — needs the
# OR runtime-schema-API integration in `SeedHelloWorld` made CI-robust.
# PHPUnit runs and is green.
# Newman stays disabled while the openbuilt#33 fixes are in flight.
# The collection bugs in this PR are fixed (uuid `@self.id` extraction,
# `export-job` slug correction, hello-world `@self.slug` fallback). But
# an upstream OR-side infrastructure blocker still prevents the seed
# from running in CI:
#
# 1. `OpenBuilt: SeedHelloWorld failed: Call to undefined function
# React\Async\await()` — OR's runtime-schema-API on `development`
# pulls `react/async` but the composer dependency isn't surfacing
# in the CI install, so the function isn't autoloaded.
# 2. SQLite (`database: sqlite`) trips
# `[PermissionHandler] no such function: REGEXP` from OR's
# MagicMapper. The query uses MySQL's REGEXP operator which
# SQLite doesn't ship.
#
# File those as their own issues; once both are unblocked, flip this
# back to `true` and re-evaluate.
enable-newman: false
database: sqlite
newman-seed-command: 'php occ app:disable openbuilt && php occ app:enable openbuilt'
Expand Down
44 changes: 39 additions & 5 deletions lib/Controller/ExportsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ private function isAuthorisedForApplication(string $applicationSlug): bool
// authed user can read OR records via the public REST surface so
// this is no weaker than the rest of the OR-backed UX — but it
// does block the "POST /exports with a guessed slug" IDOR vector.
//
// openbuilt#36: a slug-only `find($slug)` call without explicit
// register/schema context returned null because OR's
// currentRegister/currentSchema are null on this fresh service
// instance. Pass `register: 'openbuilt'` + `schema: 'application'`
// explicitly so OR resolves the slug against the right table.
// `ObjectService::find` accepts either numeric ids OR kebab slugs
// as the `$id` argument (MagicMapper:: find tolerates both), so
// the slug-only call path is correct here once we set context.
try {
if ($this->container->has('OCA\\OpenRegister\\Service\\ObjectService') === false) {
// OR not installed — no source records can exist; deny.
Expand All @@ -121,14 +130,39 @@ private function isAuthorisedForApplication(string $applicationSlug): bool
return false;
}

// Positional call: $service is untyped at this point (DI
// container returns object) so PHPStan can't verify named args.
$found = $service->find($applicationSlug);
return $found !== null;
// Use the call_user_func_array shape so PHPStan accepts the
// named-argument equivalent without seeing the untyped $service
// signature. The OR contract is documented at
// openregister/lib/Service/ObjectService.php::find($id, ..., $register, $schema, ...).
try {
$found = $service->find(
$applicationSlug,
[],
false,
'openbuilt',
'application'
);
return $found !== null;
} catch (\Throwable $findError) {
// OR's find() throws `Multiple objects found with same
// identifier` when more than one row in
// openbuilt/application shares the slug. That's a data-
// hygiene problem upstream, but for the IDOR guard the
// mere existence of >=1 row means the slug resolves — so
// we treat it as "authorised" (the same way the
// happy-path single-row return does). Any other throwable
// is logged + denied.
if (str_contains($findError->getMessage(), 'Multiple objects found') === true) {
return true;
}

$this->logger->debug('OpenBuilt export: authz fallback find() threw: '.$findError->getMessage());
return false;
}//end try
} catch (\Throwable $e) {
$this->logger->debug('OpenBuilt export: authz fallback lookup failed: '.$e->getMessage());
return false;
}
}//end try
}//end isAuthorisedForApplication()

/**
Expand Down
23 changes: 15 additions & 8 deletions src/components/ApplicationCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@
-
- ApplicationCard — custom card for the Virtual apps index grid
- (`pages[].config.cardComponent: "ApplicationCard"`). CnIndexPage
- mounts one per row passing `{ item, object, schema, register, selected }`
- and listens for `click` (→ navigate to the detail) and `select`.
- mounts one per row passing `{ item, object, schema, register, selected }`.
- The card body is a `<router-link>` to VirtualAppDetail so a click navigates
- directly to /applications/{objectId} — CnIndexPage's own `row-click`
- event is emit-only (no auto-routing), so we own the navigation here.
- Shows the virtual app's name, lifecycle-status pill, version, a "live"
- marker when a published snapshot exists, and the caller's role.
-->
<template>
<div class="ob-app-card" :class="{ 'ob-app-card--selected': selected }">
<div class="ob-app-card__inner"
role="button"
tabindex="0"
@click="$emit('click')"
@keyup.enter="$emit('click')">
<router-link
class="ob-app-card__inner"
:to="{ name: 'VirtualAppDetail', params: { objectId: appUuid } }">
<div class="ob-app-card__head">
<h3 class="ob-app-card__title">
{{ app.name || app.slug || t('openbuilt', 'Untitled app') }}
Expand All @@ -31,7 +31,7 @@
<span v-if="role !== 'none'" class="ob-app-card__chip">{{ roleLabel }}</span>
<span class="ob-app-card__chip ob-app-card__chip--muted">/{{ app.slug }}</span>
</div>
</div>
</router-link>
</div>
</template>

Expand All @@ -51,6 +51,13 @@ export default {
app() {
return this.object || this.item || {}
},
// CnDetailPage reads :objectId from $route.params, which we set here.
// OR returns the canonical id under @self.id; fall back to uuid/id for
// objects coming from older mock fixtures or pre-@self responses.
appUuid() {
const self = this.app['@self'] || {}
return self.id || this.app.uuid || this.app.id || ''
},
statusKey() {
return ['draft', 'published', 'archived'].includes(this.app.status) ? this.app.status : 'draft'
},
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
},
{
"id": "VirtualAppDetail",
"route": "/applications/:id",
"route": "/applications/:objectId",
"type": "detail",
"title": "Virtual app",
"config": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
"header": [
{ "key": "OCS-APIRequest", "value": "true" }
],
"url": "{{base_url}}/index.php/apps/openregister/api/objects/openbuilt/exportJob/{{job_uuid}}",
"url": "{{base_url}}/index.php/apps/openregister/api/objects/openbuilt/export-job/{{job_uuid}}",
"description": "Standard OR REST polling — ADR-022. Frontend uses the same endpoint at 2s intervals."
},
"event": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@
"});",
"const app = Array.isArray(results) ? results[0] : results;",
"pm.expect(app, 'application object').to.be.an('object');",
"pm.collectionVariables.set('app_uuid', app.uuid || app.id);",
"// OR REST exposes the canonical id at @self.id; fall back to legacy fields",
"// so the collection works against older / non-OR-wrapped responses too.",
"const appSelf = app['@self'] || {};",
"const appUuid = appSelf.id || appSelf.uuid || app.uuid || app.id;",
"pm.expect(appUuid, 'no uuid in application response').to.be.a('string');",
"pm.collectionVariables.set('app_uuid', appUuid);",
"pm.collectionVariables.set('original_manifest', JSON.stringify(app.manifest));",
"pm.collectionVariables.set('original_application', JSON.stringify(app));"
]
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/openbuilt.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@
" pm.expect(results.length).to.be.greaterThan(0);",
"});",
"pm.test('at least one Application has slug=hello-world', function () {",
" const hello = results.find(function (r) { return r && r.slug === 'hello-world'; });",
" // OR REST returns the canonical slug at @self.slug; some fixtures",
" // also surface a top-level .slug copy. Accept either so the suite",
" // works regardless of which version of OR is under test.",
" const hello = results.find(function (r) { return r && ((r['@self'] && r['@self'].slug === 'hello-world') || r.slug === 'hello-world'); });",
" pm.expect(hello, 'no Application with slug=hello-world found').to.not.be.undefined;",
"});"
]
Expand Down
Loading