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
26 changes: 26 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,21 @@ Vrij en open source onder de EUPL-1.2-licentie.
a fresh install has no stages to repair.
-->
<step>OCA\Decidiq\Migration\RepairDecisionStageLabels</step>
<!--
Fifth leg of the rename, on the ADMINISTRATORS GROUP. The
register's authorization baseline named `decidesk-administrators`
and OpenRegister's GroupProvisioner creates every group an
authorization block names, so existing installs hold the group —
and its hand-granted memberships — under the OLD app id. The
baseline now names `decidiq-administrators` first (the old id
stays honored); this step copies the old group's members into
the new one. After InitializeSettings on purpose: the import it
triggers provisions the new group, so this step normally only
copies members. No rename exists for a Nextcloud gid, and the
old group is never deleted and never loses a member — same
rollback posture as the other rename legs. Idempotent.
-->
<step>OCA\Decidiq\Repair\MigrateAdminGroup</step>
</post-migration>
<install>
<!--
Expand Down Expand Up @@ -372,6 +387,17 @@ Vrij en open source onder de EUPL-1.2-licentie.
yet. Idempotent; never overwrites a stored vocabulary.
-->
<step>OCA\Decidiq\Repair\SeedDecisionTypes</step>
<!--
Same step as in <post-migration>, for the same reason the other
rename legs appear in both blocks: <install> is the path a real
deployment takes, because Nextcloud discovers `decidiq` as a NEW
app rather than as an upgrade of `decidesk` — and an instance
that ran the old app is exactly where `decidesk-administrators`
exists with members to carry over. On a genuinely fresh install
the old group does not exist and the step is a no-op; the new
group is provisioned (empty) by the register import instead.
-->
<step>OCA\Decidiq\Repair\MigrateAdminGroup</step>
</install>
</repair-steps>

Expand Down
208 changes: 208 additions & 0 deletions lib/Repair/MigrateAdminGroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
<?php

/**
* Decidiq Migrate Admin Group Repair Step
*
* Repair step that carries the app's administrators group across the
* `decidesk` -> `decidiq` app-id rename.
*
* The register configuration's authorization baseline names an
* administrators group for the griffie/secretariat that must edit records it
* did not create, and OpenRegister's GroupProvisioner CREATES every group an
* authorization block names during the register import. Until this change the
* block named `decidesk-administrators` — the pre-rename app id — so every
* existing install holds a group under the OLD id, with real memberships an
* administrator granted by hand.
*
* WHY MIGRATE RATHER THAN RENAME. A Nextcloud group id (gid) is the primary
* key of the group across every backend; `IGroupManager` exposes create and
* delete but no rename, and `IGroup::setDisplayName()` changes the label, not
* the id the authorization rows match on. The only rename available would be
* delete-and-recreate, which destroys memberships and shares. So this step
* CREATES `decidiq-administrators` (when the import's GroupProvisioner has not
* already) and COPIES every member of the old group into it.
*
* WHY THE OLD GROUP STAYS, HONORED. The authorization arrays in
* `lib/Settings/decidesk_register.json` now name BOTH groups, new id first.
* The old group is never deleted and never has a member removed, for the same
* reason the other rename legs keep their old rows: a rollback still finds
* them, and an install where this step could not copy a member (an LDAP-backed
* read-only group backend refuses local writes) keeps working through the old
* name. The old name can only be retired once no install's group backend
* depends on it — a decision about data, not a migration.
*
* SAFETY. Idempotent and non-destructive:
* - when the old group does not exist there is nothing to migrate and the
* step is a no-op — a fresh install gets the NEW group provisioned empty
* by the register import, preserving the documented admin-provisioned,
* fail-closed posture (no group means owner-plus-admin only);
* - a member already in the new group is skipped, so a second run is a
* no-op;
* - every failure is logged and the loop continues. This step is registered
* under `<install>`, where a throwing repair step means the app never
* enables at all — one uncopyable membership is not worth that.
*
* Registered under BOTH `<install>` and `<post-migration>` in
* `appinfo/info.xml`, after InitializeSettings — the import that step
* triggers provisions the new group, so this step normally only copies
* members (and creates the group itself only when the import could not).
*
* @category Repair
* @package OCA\Decidiq\Repair
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>.
* SPDX-License-Identifier: EUPL-1.2
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*/

declare(strict_types=1);

namespace OCA\Decidiq\Repair;

use OCP\IGroupManager;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Copy the decidesk-administrators group's members to decidiq-administrators.
*
* @spec exclude No canonical spec covers the `decidesk` -> `decidiq`
* administrators-group migration. Pointing this at an existing spec would
* report conformance to a requirement that says nothing about it.
*/
class MigrateAdminGroup implements IRepairStep {

/**
* The administrators group id under the OLD app id.
*
* Deliberately still `decidesk`: this constant and the honored entry in
* `lib/Settings/decidesk_register.json` are the places that are supposed
* to keep saying it.
*
* @var string
*/
private const OLD_GROUP_ID = 'decidesk-administrators';

/**
* The canonical administrators group id under the current app id.
*
* @var string
*/
private const NEW_GROUP_ID = 'decidiq-administrators';

/**
* Constructor for MigrateAdminGroup.
*
* @param IGroupManager $groupManager Group existence, creation and membership.
* @param LoggerInterface $logger Logger for members that fail to copy.
*
* @return void
*/
public function __construct(
private readonly IGroupManager $groupManager,
private readonly LoggerInterface $logger,
) {

}//end __construct()

/**
* The repair step name.
*
* @return string
*
* @spec exclude One-off decidesk->decidiq app-id rename plumbing: it
* mirrors group memberships between the old-id and new-id
* administrators groups and adds no behaviour of its own.
*/
public function getName(): string {
return 'Copy the Decidiq administrators group from the decidesk group id';
}//end getName()

/**
* Ensure decidiq-administrators exists and carries every member of the
* old-id group.
*
* @param IOutput $output Repair output channel.
*
* @return void
*
* @spec exclude One-off decidesk->decidiq app-id rename plumbing: it
* mirrors group memberships between the old-id and new-id
* administrators groups and adds no behaviour of its own. The
* authorization the groups feed is specified in
* lib/Settings/decidesk_register.json's register-level baseline.
*/
public function run(IOutput $output): void {
try {
if ($this->groupManager->groupExists(self::OLD_GROUP_ID) === false) {
// Nothing to migrate. The register import provisions the new
// group (empty) from the authorization declaration; creating
// it here too would only duplicate that work.
return;
}

$oldGroup = $this->groupManager->get(self::OLD_GROUP_ID);
if ($oldGroup === null) {
return;
}

if ($this->groupManager->groupExists(self::NEW_GROUP_ID) === false) {
$this->groupManager->createGroup(self::NEW_GROUP_ID);
}

$newGroup = $this->groupManager->get(self::NEW_GROUP_ID);
if ($newGroup === null) {
$this->logger->warning(
'Decidiq: could not resolve the new administrators group; '
. 'the old-id group stays honored by the authorization baseline',
['group' => self::NEW_GROUP_ID]
);
return;
}

$copied = 0;
foreach ($oldGroup->getUsers() as $user) {
try {
if ($newGroup->inGroup($user) === true) {
continue;
}

$newGroup->addUser($user);
$copied++;
} catch (Throwable $e) {
$this->logger->warning(
'Decidiq: could not copy one administrators-group member; '
. 'the member keeps access through the old-id group',
['user' => $user->getUID(), 'exception' => $e->getMessage()]
);
}
}//end foreach

$output->info(
sprintf(
'Decidiq administrators group: copied %d member(s) from %s to %s',
$copied,
self::OLD_GROUP_ID,
self::NEW_GROUP_ID
)
);
} catch (Throwable $e) {
// A repair step registered under <install> that throws stops the
// app enabling entirely; a group backend hiccup is not worth that.
$this->logger->error(
'Decidiq: administrators-group migration failed; '
. 'members keep access through the old-id group',
['exception' => $e]
);
}//end try
}//end run()
}//end class
6 changes: 4 additions & 2 deletions lib/Settings/decidesk_register.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@
"slug": "decidiq",
"title": "Decidiq Register",
"description": "Universal decision-making platform for governance bodies, associations, corporate boards, and operational meetings. Holds every governance body, meeting, agenda item, decision, vote, minutes, consultation and integrity object the app reads and writes.",
"version": "0.12.0",
"_authorizationNote": "REGISTER-LEVEL BASELINE, and it is a BEHAVIOUR CHANGE. Until this block existed, every decidesk schema without its own `authorization` block was read AND WRITE open to every authenticated user through OpenRegister's own /apps/openregister/api/objects/decidiq/<schema> API — the API the frontend uses directly under ADR-022, which no decidesk controller guard sits in front of. OpenRegister's PermissionHandler::hasGroupPermission() tests `empty($authorization)`, and PHP's empty() is true for null and [] alike, so an ABSENT block takes the same default-OPEN branch as an empty one; `enforce_default_closed` defaults to false and, even when enabled, closes only create/update/delete. Measured on this tree before the change: 93 schemas, 24 carrying a block (all read-only), this register row carrying none — so 69 schemas, including Decision, VotingRound, Vote, Participant and EngagementRecord, granted create/update/delete to any logged-in account. Same shape as docudesk#631, where a plain user overwrote another user's template. THE CASCADE IS WHY THIS SITS ON THE REGISTER ROW: PermissionHandler::resolveAuthorization() uses a schema's own block when it has one and falls back to the register's only when it does not, so this baseline reaches exactly the 69 unprotected schemas and changes nothing for the 24 that already declare their own (their public-read rules are untouched). WHAT EACH ACTION IS FOR: read and list name BOTH `authenticated` AND `public`, and create names `authenticated`, so no read goes dark and any member can still raise a decision, cast a vote or file a reaction — those flows are unchanged. `public` on the READ actions is not a widening: before this block existed the default-OPEN branch granted every action to every principal INCLUDING the anonymous one, so omitting `public` here would have CLOSED anonymous reads — a separate and much larger policy change than the write hole this block exists to fix, and one that would 403 every #[PublicPage] citizen-participation surface. CI proved it rather than theory: the first version of this block omitted `public` and all six PHPUnit legs failed with `User 'Anonymous' does not have permission to 'read' objects in schema 'Meeting'`. `public` appears on NO write action, so the anonymous fail-closed rule from openregister#1955 keeps denying anonymous create/update/delete exactly as it did before. update/delete are NOT granted to `authenticated`, which is the whole fix: OpenRegister bypasses the object OWNER unconditionally and SQL-side before any rule is consulted, and bypasses `admin` too, so the author of an object keeps full control of it and only OTHER users lose the ability to rewrite or destroy it. `decidesk-administrators` is the named Nextcloud group for a griffie/secretariat that must edit records it did not create; it is admin-provisioned, and on an instance where it does not exist this simply resolves to owner-plus-admin, which is fail-closed. EVERY ACTION IS WRITTEN OUT ON PURPOSE: once a block is non-empty, OpenRegister DENIES any action the block omits, so an omitted `list` or `create` here would silently break the app rather than secure it. Per-body actor authorization (chair / signatory scopes, x-decidesk-rbac-scopes above) is a separate layer and is unaffected — OpenRegister cannot template a group name per object, which is why that determination is made at the app boundary.",
"version": "0.13.0",
"_authorizationNote": "REGISTER-LEVEL BASELINE, and it is a BEHAVIOUR CHANGE. Until this block existed, every decidesk schema without its own `authorization` block was read AND WRITE open to every authenticated user through OpenRegister's own /apps/openregister/api/objects/decidiq/<schema> API — the API the frontend uses directly under ADR-022, which no decidesk controller guard sits in front of. OpenRegister's PermissionHandler::hasGroupPermission() tests `empty($authorization)`, and PHP's empty() is true for null and [] alike, so an ABSENT block takes the same default-OPEN branch as an empty one; `enforce_default_closed` defaults to false and, even when enabled, closes only create/update/delete. Measured on this tree before the change: 93 schemas, 24 carrying a block (all read-only), this register row carrying none — so 69 schemas, including Decision, VotingRound, Vote, Participant and EngagementRecord, granted create/update/delete to any logged-in account. Same shape as docudesk#631, where a plain user overwrote another user's template. THE CASCADE IS WHY THIS SITS ON THE REGISTER ROW: PermissionHandler::resolveAuthorization() uses a schema's own block when it has one and falls back to the register's only when it does not, so this baseline reaches exactly the 69 unprotected schemas and changes nothing for the 24 that already declare their own (their public-read rules are untouched). WHAT EACH ACTION IS FOR: read and list name BOTH `authenticated` AND `public`, and create names `authenticated`, so no read goes dark and any member can still raise a decision, cast a vote or file a reaction — those flows are unchanged. `public` on the READ actions is not a widening: before this block existed the default-OPEN branch granted every action to every principal INCLUDING the anonymous one, so omitting `public` here would have CLOSED anonymous reads — a separate and much larger policy change than the write hole this block exists to fix, and one that would 403 every #[PublicPage] citizen-participation surface. CI proved it rather than theory: the first version of this block omitted `public` and all six PHPUnit legs failed with `User 'Anonymous' does not have permission to 'read' objects in schema 'Meeting'`. `public` appears on NO write action, so the anonymous fail-closed rule from openregister#1955 keeps denying anonymous create/update/delete exactly as it did before. update/delete are NOT granted to `authenticated`, which is the whole fix: OpenRegister bypasses the object OWNER unconditionally and SQL-side before any rule is consulted, and bypasses `admin` too, so the author of an object keeps full control of it and only OTHER users lose the ability to rewrite or destroy it. `decidiq-administrators` is the named Nextcloud group for a griffie/secretariat that must edit records it did not create. It was first shipped as `decidesk-administrators` — the pre-rename app id — and OpenRegister's GroupProvisioner creates every group an authorization block names, so existing installs hold that old-id group with real memberships. BOTH names therefore stay in the arrays: dropping the old one would strip access from every member the moment this row re-imports, before the MigrateAdminGroup repair step has copied anyone (and on an install where the repair cannot add a member — an LDAP-backed read-only group backend — forever). MigrateAdminGroup copies members old-to-new; the old group is never deleted and never has members removed, matching the other rename legs. On an instance where neither group exists this resolves to owner-plus-admin, which is fail-closed. EVERY ACTION IS WRITTEN OUT ON PURPOSE: once a block is non-empty, OpenRegister DENIES any action the block omits, so an omitted `list` or `create` here would silently break the app rather than secure it. Per-body actor authorization (chair / signatory scopes, x-decidesk-rbac-scopes above) is a separate layer and is unaffected — OpenRegister cannot template a group name per object, which is why that determination is made at the app boundary.",
"authorization": {
"read": [
"authenticated",
Expand All @@ -72,9 +72,11 @@
"authenticated"
],
"update": [
"decidiq-administrators",
"decidesk-administrators"
],
"delete": [
"decidiq-administrators",
"decidesk-administrators"
]
},
Expand Down
22 changes: 18 additions & 4 deletions src/components/tabs/AgendaMotionsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,18 @@ export default {
]
},

/** @spec openspec/specs/relation-tab-ui/spec.md */
/**
* `decisionType` is excluded because it is this tab's DISCRIMINATOR,
* not a choice: every row here is a Decision of decisionType=motion
* (the fetch filters on it) and onConfirm writes the value itself.
* Leaving it on the form showed an EMPTY required picker —
* decision-types-as-configuration (#1099) dropped the enum from the
* stored schema, so the select had no options and blocked the save.
*
* @spec openspec/specs/relation-tab-ui/spec.md
*/
excludedFields() {
return ['id', 'uuid', 'agendaItem', 'created', 'updated']
return ['id', 'uuid', 'agendaItem', 'decisionType', 'created', 'updated']
},
},

Expand Down Expand Up @@ -209,7 +218,7 @@ export default {
},

/**
* @param row
* @param row The decision row to edit.
* @spec openspec/specs/relation-tab-ui/spec.md
*/
async openEdit(row) {
Expand All @@ -221,14 +230,19 @@ export default {
},

/**
* @param formData
* @param formData The submitted form values.
* @spec openspec/specs/relation-tab-ui/spec.md
*/
async onConfirm(formData) {
const store = ensureRelationType('motion')
try {
// The discriminator is written HERE, not picked on the form —
// see excludedFields. Without this, a create carried no
// decisionType at all and the row fell out of this tab's own
// decisionType=motion filter on the next refresh.
await store.saveObject('motion', {
...formData,
decisionType: 'motion',
agendaItem: this.objectId,
})
this.$refs.formDialog?.setResult({ success: true })
Expand Down
Loading
Loading