Skip to content

fix(organisations): never take over an existing organisation on a name match (REVIEW) - #1418

Merged
rubenvdlinde merged 9 commits into
developmentfrom
fix/stackiq-refuse-slug-adoption
Nov 3, 2026
Merged

rubenvdlinde merged 9 commits into
developmentfrom
fix/stackiq-refuse-slug-adoption

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

REVIEW (organisation ownership and membership). Held. This implements Ruben's decision on adopt-by-slug. It is stacked on #1405, because it changes adoptOrganisationEntityBySlug, which #1405 extracted. #1405's commits leave this diff as soon as it merges.

The problem

When an organisatie had no OpenRegister organisation yet, OrganizationSyncService looked one up by the slug of the organisatie's name. On a match it wrote the organisatie's uuid onto that organisation and made it the organisatie's owner. A registration named like an existing organisation therefore took that organisation over.

Two related paths had the same flaw:

  • ContactPersonHandler. ensureOrganizationEntity did the same for a contact person's organisation, and addUserToOrganizationEntity then added the contact's user to that other organisation.
  • The 100-character cut. Both paths computed the slug without OpenRegister's 100-character cut. A long name therefore missed the match, and OpenRegister's own createOrganisation() slug collision then overwrote the existing organisation. ORX8's openregister#5462 fixes that side with a 409.

What changes

  • A taken slug is refused on both paths.
    • Nothing is created, re-uuided or joined.
    • A neutral warning names the organisatie and the uuid of the organisation that holds the slug: "an organisation with this name already exists; the organisatie is left unlinked for an administrator".
    • The sync counts it under slugConflicts, and the organisatie stays among the active organisaties without an entity.
    • The contact path's caller now logs at info level instead of error.
    • A missing or "Unknown" name is not checked, because it gets a uuid-based name at creation.
  • The slug is computed exactly as OpenRegister does it. OrganisationSlug::fromName() mirrors OrganisationService::generateSlug(): lower case, runs of other characters become one hyphen, trimmed, and cut at 100 characters.
  • OpenRegister's refusal (openregister#5462). OrganisationSlugConflictException (409) is handled as the same refusal. OrganisatieService::createOrganisationInOpenRegister() passes it on instead of swallowing it; every other failure still ends as null. The class is matched by name, so older OpenRegister releases without it keep working.
  • An explicit admin link: occ stackiq:organisation:link <organisatie-uuid> <organisation-uuid>.
    • The organisation takes the organisatie's uuid, which is what links the two, and becomes the organisatie object's owner.
    • It is refused for an unknown organisatie, an organisatie that already has an organisation, an unknown organisation, and an organisation that already belongs to another organisatie.
    • occ is administrator-only. No HTTP endpoint is added.

Evidence

Golden, recorded on the old code first. OrganizationSyncEntityGoldenTest gains five cases: same name as another organisation, long name with the slug cut at 100, long name with a free slug, OpenRegister refusing the slug, and the name "Unknown". The old cases are unchanged. The changed recordings:

case old new
adopted by slug / same name returns the other organisation re-uuided to the organisatie (setUuid, save, owner write) null, no write, slugConflicts: [o-1], warning
long name, slug cut missed, created through OpenRegister findBySlug with the 100-character slug, refused, no create
long name, free slug created created (only the slug argument changes)
OpenRegister refuses the generic exception path neutral warning, counted
"Unknown" took over the organisation named "unknown" created with the uuid-based name

Failing first:

  • testASameNamedRegistrationDoesNotCaptureTheExistingOrganisation (a short and a 120-character name) failed on the old code.
  • ContactPersonHandlerSlugConflictTest (same name not joined, refused slug joins nothing with a warning and no error, free slug creates and joins) failed on the old code.
  • OrganisatieServiceSlugConflictTest (the refusal is passed on, other failures are null) failed on the old code.

Link: testLinkOrganisationEntity covers all five outcomes and asserts the writes. OrganisationLinkCommandTest covers 6 outcomes and exit codes.

Mutants (capped, with a no-op control that survived):

file killed
OrganizationSyncService 12 of 12 (slug check skipped, "Unknown" checked, not counted, 409 escaping, the neutral log dropped, and each link guard plus the uuid move and the owner write)
OrganisationSlug 2 of 2 (no 100-character cut, hyphens kept)
ContactPersonHandler 3 of 3 (same name joined, the old slug rule, the 409 not recognised)
OrganisatieService 2 of 2

Numbers (PHP, lizard, against development plus #1405)

base this PR
params >= 3 15.30% 15.26%
size > 60 32.91% 32.81%
McCabe > 10 17.24% 17.19%

Verified locally

  • Diff-scoped checks on the touched lib files: php -l, phpcs, phpstan, psalm and phpmd are clean. phpmd CouplingBetweenObjects on OrganisatieService stays under its limit, because the conflict class is named by string. Gate 119: 0.
  • The touched tests pass under pcov with --fail-on-risky: 78 tests, exit 0. @uses OrganisationSlug is added to the tests that cover the two services.
  • development is merged in, without a rebase.
  • The full unit suite and the hydra gates are queued. Their results follow in a comment.

Assisted-by: ClaudeCode:claude-opus-5-5

…to find, fetch and process steps

processRelatedContactPersons drops its unused organisation object parameter and
hands the work to findRelatedContacts, contactsFromOrganisationList,
fetchListedContact, processRelatedContact and completeRelatedContact, each with
at most two parameters (the sync run array, stats held by reference). Lookups,
saves, log lines and stats are unchanged (golden of the previous commit).

Assisted-by: ClaudeCode:claude-opus-5-5
…of an organisatie object

The OrganisationMapper stub gains findBySlug, as on OpenRegister's mapper.

Assisted-by: ClaudeCode:claude-opus-5-5
…nto refresh, adopt-by-slug and create steps

ensureOrganisationEntity takes the organisatie object and one run array (stats
held by reference, and an explicit sendEmails at every call site) and hands
the work to fullOrganisationObject, refreshOrganisationEntity,
adoptOrganisationEntityBySlug, createOrganisationEntity, mailOrganisation and
makeEntityOwner. The public wrapper keeps its signature. Lookups, saves,
mails, log lines and stats are unchanged (golden of the previous commit).

Assisted-by: ClaudeCode:claude-opus-5-5
… it behaves today

Creating an organisation escapes with an Error on both paths: the signed-in
path calls OrganisationMapper::createWithUuid, which OpenRegister does not
have, and the anonymous path saves and then reads the absent user.

Assisted-by: ClaudeCode:claude-opus-5-5
…m the OpenRegister create and update steps

Neither createOrganisationInOpenRegisterInternal nor updateOrganisationInOpenRegister
reads the OrganisationService it was handed. The public create wrapper still
fetches it first, so a missing OpenRegister fails at the same point. The golden
masks mock and anonymous class names, which depend on test order; it was
re-recorded on the code before this commit.

Assisted-by: ClaudeCode:claude-opus-5-5
…e match

When an organisatie had no OpenRegister organisation yet, the sync looked one
up by the slug its name gives and, on a match, wrote the organisatie's uuid
onto that organisation: a registration named like an existing organisation
took it over. ContactPersonHandler did the same for a contact person's
organisation and then added the contact's user to it. The slug was also
computed without OpenRegister's 100-character cut, so a long name missed the
match and OpenRegister's own create overwrote the existing organisation.

Now a taken slug is refused on both paths: nothing is created or changed, a
neutral warning names the organisatie and the organisation holding the slug,
and the sync counts it under slugConflicts. OpenRegister's
OrganisationSlugConflictException (openregister#5462, a 409) is handled the
same way. OrganisationSlug computes the slug exactly as OpenRegister does.

`occ stackiq:organisation:link <organisatie> <organisation>` lets an
administrator link the two explicitly. It refuses an unknown organisatie or
organisation, an organisatie that already has one, and an organisation that
belongs to another organisatie.

Assisted-by: ClaudeCode:claude-opus-5-5
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Local evidence on the head: full unit suite (phpunit-unit.xml, pcov, --fail-on-risky, through heavy.sh) exit 0, 1633 tests, 0 failures, 0 risky, 59 skipped. Hydra gates --scope-to-diff --base origin/development: 37 of 37 applicable gates ran, 0 FAIL.

@rubenvdlinde
rubenvdlinde merged commit 96c2189 into development Nov 3, 2026
46 of 49 checks passed
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/stackiq @ 39b62e7

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ✅
stylelint ✅
build ✅
check-manifest ✅
check-vue-demi ✅
test-l10n ✅
format ✅
check-schema-l10n ❌
check-l10n-js ✅
composer ✅ ✅ 127/127
npm ❌ ✅ 807/807
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ✅ 1613/1613 · 20 skipped
Newman ⏭️
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development.
Hydra gates ❌

Quality workflow — 2026-11-04 01:17 UTC

Download the full PDF report from the workflow artifacts.

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.

1 participant