Skip to content

feat(forms): a per-form DigiD or eHerkenning sign-in level on both publish paths - #942

Merged
rubenvdlinde merged 2 commits into
developmentfrom
fix/935-form-sign-in-level
Sep 27, 2026
Merged

rubenvdlinde merged 2 commits into
developmentfrom
fix/935-form-sign-in-level

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What the user saw

A maker could not say that a form may only be filled in after signing in with DigiD or eHerkenning. Every form opened to the outside was published as anonymous. The only way to put DigiD in front of a form was to make the whole portal DigiD-only.

Cause

  • Form pages (External access): externalFormProvisioningService.js hard-coded anonymous: true with minTrust: 0 on the create action and on the page. 0 is not a value portaliq knows (Opening a form page to external submitters writes a portal page portaliq cannot use #921).
  • A repeat save merged that anonymous entry back over the stored action, so a level set by hand in portaliq was reset.
  • Registration forms: the registrationForm schema had no trust field at all, so portaliq had nothing to enforce.

The value set (coordinated with portaliq#725)

minTrust is one of low, substantial or high, portaliq's own enum. There is no anonymous value: absence means anonymous. buildiq sends a level only when the maker chose one, and portaliq enforces it (portaliq#725, merged as portaliq#727, reads registrationForm.minTrust in PortalFormBindingResolver::render()).

What changed

  • Registration forms
    • The schema gets an optional minTrust (enum low|substantial|high, version 0.3.0 to 0.4.0).
    • The form editor gets a "Sign-in required" select. Choosing a level turns isPublic off; ticking "Anyone may fill this in" removes the level; "No sign-in" removes the key rather than storing a value.
    • The server refuses an unknown level (including 0) and a form that is both public and signed-in, in RegistrationFormSignInLevel::assertValid(), called from the validator. It lives in its own class because the validator would otherwise cross the phpmd class complexity threshold.
    • The served form (RegistrationFormPresenter::serve()) carries minTrust.
  • Form pages
    • The External access dialog gets the same select. The toggle now reads "Open this form to people outside the organisation" instead of promising anonymity.
    • provisionPortalPage({ minTrust }) handles the level three ways:
      • a level sets minTrust on the create action and on the collection, without anonymous;
      • null (the maker explicitly chose no sign-in) makes both entries anonymous;
      • left out (the maker did not touch the select), a stored level is kept, so a repeat save no longer writes the anonymous entry back over a raised level.
    • The invalid minTrust: 0 is never written, and a stored 0 is dropped on the next save (Opening a form page to external submitters writes a portal page portaliq cannot use #921 comment).
  • No bypass of the sign-in. When a level is set, the dialog grants no OpenRegister public create on the target schema, and it takes back a grant an earlier anonymous save gave. portaliq writes with _rbac: false (PortalObjectWriter), so the grant is not needed for the portal path; keeping it would let anyone POST straight to OpenRegister past the sign-in. The raw anonymous submit URL is not shown for such a form.
  • Parity rows

Tests (all red before the fix, green after)

  • tests/services/externalFormProvisioningService.spec.js: 8 new or changed cases. They cover a level on create for each of the three values, keeping a stored level on a repeat save, raising, explicitly lowering, dropping a stored 0, and no minTrust on an anonymous page. Before the fix: 8 red.
  • tests/dialogs/ExternalFormAccessDialog.spec.js: 5 new cases. They cover the options offered, the level being sent, no public create grant, revoking the earlier grant, leaving the level alone, and choosing none. Before the fix: 5 red.
  • tests/components/RegistrationFormEditorSignIn.spec.js: 3 cases, all red before the fix.
  • tests/Unit/Service/RegistrationFormValidatorTest.php (4 new), RegistrationFormFragmentTest.php (schema declares the enum, not required) and RegistrationFormPresenterSignInTest.php (2 new). Before the fix: 4 red in the validator and fragment tests, 1 red in the presenter test.

Verified

  • npx vitest run on the four touched specs: exit 0 (45 passed)
  • vendor/bin/phpunit -c phpunit-unit.xml --filter RegistrationForm: exit 0 (70 tests)
  • COMPOSER_PROCESS_TIMEOUT=0 composer check:strict: exit 1, only from gate-101 demo-data-coverage (inherited, see below). lint, phpcs, phpmd, psalm, phpstan and PHPUnit (1308 tests OK) are green. The first run flagged phpmd ExcessiveClassComplexity on RegistrationFormValidator (52 of 50), caused by this change; I fixed it in the second commit.
  • npm run lint: exit 0
  • npm run format: exit 0
  • npm run test:l10n: exit 0
  • npm run check:schema-l10n: exit 0. The two new schema strings are in en.json and nl.json, and the new UI strings are translated to Dutch too.
  • npm run check:l10n-js: exit 0
  • python3 parity_verify.py --strict openspec/parity/capabilities.json (hydra development): exit 1, only the inherited unknown-cells listing, the same as on development.

Known gap, filed in portaliq

portaliq's PortalFormTrustLevel treats a declared low on a registration form as anonymous, while its portalPage contract treats low as signed in. So a form set to "level low" is still served without a session until portaliq#731 is fixed. substantial and high are enforced.

Inherited findings

gate-101 demo-data-coverage is red on development: Application, ApplicationTemplate, ApplicationVersion, BuiltAppRoute and exportJob have no demo objects. The registrationForm demo data stays valid.

Fixes #935

🤖 Generated with Claude Code

… per form

Every form a maker opened to the outside was anonymous: the form page path
hard-coded minTrust 0 (not a value portaliq knows) and the registration form
had no trust field. Both paths now carry a per-form sign-in level from
portaliq's value set (low, substantial, high; absent is anonymous). A repeat
form page save that names no level keeps a stored level instead of writing
the anonymous entry back over it, and a form that asks for a sign-in gets no
OpenRegister public create grant, since portaliq writes past RBAC itself.
Parity rows form-digid-login and form-public updated.

Fixes #935
The rule pushed RegistrationFormValidator over the phpmd class complexity
threshold (52 of 50).
@rubenvdlinde
rubenvdlinde merged commit 23a7af8 into development Sep 27, 2026
1 check passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/buildiq @ 6082886

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ✅
stylelint ✅
build ✅
check-manifest ✅
test-l10n ✅
check-gitignore ✅
check-nc-floor ✅
format ✅
check-l10n-js ✅
check-schema-l10n ✅
composer ✅ ✅ 106/106
npm ✅ ✅ 752/752
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ✅
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 on any branch from the Actions tab, or locally with npx playwright test.
Hydra gates ✅

Quality workflow — 2026-09-27 07:41 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