Skip to content

fix(gate-54): a genuine cross-app reference could not be authored at all - #286

Merged
rubenvdlinde merged 1 commit into
mainfrom
fix/gate-54-cross-app-relation-unclosable
Aug 9, 2026
Merged

fix(gate-54): a genuine cross-app reference could not be authored at all#286
rubenvdlinde merged 1 commit into
mainfrom
fix/gate-54-cross-app-relation-unclosable

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

The gate was unclosable

x-external-register appears zero times in check_relation_dialect.py. The checker has no concept of a reference to a schema owned by another app — and for such a property both arms fail:

Authoring Verdict
"$ref": "case" (f) $ref 'case' does not resolve to a schema key in the register set (case-exact)
no $ref (b) relation-shaped property (format:uuid + relation description) lacks canonical $ref (ADR-062 rule 7)

Reproduced directly against main (e7bde0a), one finding on each arm.

Measured on docudesk: correspondence.caseReference and generatedDocument.zaakId both reference a Zaak that lives in Procest's register, and both already declare x-external-register: procest. There is no authoring that satisfies the gate. The only route to green was to reword the description until _RELATION_DESC_RE stopped matching — degrading documentation to dodge a regex, which is exactly what a gate must never reward. This is gate-59's own subject matter.

The rule

OpenRegister resolves $ref inside one register set, so a foreign schema is not expressible as a relation. A property carrying x-external-register is therefore a plain identifier: it must not be asked for a $ref, and it must not carry one.

The exemption is narrow, and the true positive survives

  • rule (b) skips a property that declares x-external-register;
  • rule (f) still reports one that declares both x-external-register and a $ref — that $ref is one OpenRegister can never resolve. The message now names the actual fix ("drop the $ref") instead of the generic "does not resolve", which pointed the author in the wrong direction and is what produced the flip-flop in the first place;
  • a dangling $ref without the marker keeps its original message;
  • filter-token validation is untouched.

Tests

Four added to test_check_relation_dialect.py, each failing before this change:

  • test_external_reference_without_ref_is_accepted — the closable arm
  • test_external_reference_with_a_dangling_ref_is_still_reported — true positive survives, message is actionable
  • test_a_local_dangling_ref_is_unaffected — control: widening a checker until it catches nothing is not a fix
  • test_external_marker_does_not_excuse_a_bad_filter_token — control: the exemption is scoped to the $ref rules only

Full helper suite: 28 tests, all green (24 pre-existing + 4 new). Verified the 2 pre-fix failures with python3 -m unittest before implementing.

End-to-end

docudesk gate-54: FAIL — 2 findingsPASS, with its register also corrected to drop the two dangling $refs (ConductionNL/docudesk, separate PR). Both halves were required: the register fix alone flipped the finding to the other arm.

`x-external-register` appeared ZERO times in check_relation_dialect.py, so the
checker had no concept of a reference to a schema owned by another app — and
for such a property BOTH arms of the gate failed:

  WITH    "$ref": "case"  -> (f) "$ref 'case' does not resolve to a schema key
                                  in the register set (case-exact)"
  WITHOUT "$ref"          -> (b) "relation-shaped property (format:uuid +
                                  relation description) lacks canonical $ref
                                  (ADR-062 rule 7)"

Measured 2026-08-09 on docudesk at package e7bde0a: `correspondence.
caseReference` and `generatedDocument.zaakId` both point at a Zaak that lives
in Procest's register, and both are marked `x-external-register: procest`.
There was no authoring that satisfied the gate. The only route to green was to
reword the description until `_RELATION_DESC_RE` stopped matching — degrading
documentation to dodge a regex, which is exactly what a gate must never reward.
That is an unclosable gate (gate-59's own subject matter).

OpenRegister resolves `$ref` inside ONE register set, so a foreign schema is
not expressible as a relation. A property carrying `x-external-register` is
therefore a plain identifier: it must not be asked for a `$ref`, and it must
not carry one.

The exemption is deliberately narrow, and the true positive survives:

  * rule (b) skips a property that declares `x-external-register`;
  * rule (f) still reports one that declares BOTH `x-external-register` and a
    `$ref`, because that `$ref` is one OpenRegister can never resolve — with a
    message naming the actual fix ("drop the $ref") instead of the generic
    "does not resolve", which pointed the author the wrong way;
  * a dangling `$ref` WITHOUT the marker keeps its original message;
  * filter-token validation is untouched.

Four tests added, each of which fails before this change (two on the message,
two as controls that pin the widening). Full helper suite: 28 tests, all green.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Independent confirmation from a second repo and a different cross-app shape — this fixes openconnector too.

I hit the same wall on openconnector and filed #289 before seeing this. Same working tree, two package refs, nothing changed but the checker:

package ref gate-54
c51a225 / 651e5c5 (main) FAILlacks canonical $ref (ADR-062 rule 7)
7f5f9e7 (this PR) PASS

The property is source.configuration.authentication.credentialRef.credentialId in lib/Settings/openconnector_register.json, annotated x-external-register: openregister with no $ref.

Why this is a useful second data point

docudesk's cases point at a Zaak in Procest. Mine points at a credential in OpenRegister's credential broker:

  • different owning app,
  • different reason for the boundary — the secret must never be stored on the source, so openconnector deliberately holds only the identifier,
  • and it is nested three levels deep (configurationauthenticationcredentialRefcredentialId) rather than sitting at the schema root.

_is_external_ref() handles it unmodified. Keying the exemption on the property rather than on the register's shape is what makes that work, and it is the right axis.

The narrowness holds

I confirmed the exemption suppresses only the two $ref rules — a property carrying both x-external-register and a $ref is still reported, which is correct, since that $ref is one OpenRegister can never resolve. The true positive is preserved.

One note for whoever merges this

Until this lands, the annotation is inert on main and the gate reports the original finding byte-for-byte — so a repo that adopts x-external-register early sees no change and might reasonably conclude the key does nothing. grep -c x-external-register check_relation_dialect.py is 0 on 651e5c5. I am landing the annotation on openconnector anyway, because the property genuinely is a foreign-register reference and documenting that is correct regardless; openconnector's gate-54 will go green the moment this merges, with no further change on my side.

#289 can be closed as a duplicate of this once it merges.

@rubenvdlinde
rubenvdlinde merged commit 365fa31 into main Aug 9, 2026
30 checks passed
rubenvdlinde added a commit to ConductionNL/docudesk that referenced this pull request Aug 9, 2026
…ured (gates 1, 50, 54) (#414)

* fix(spdx): PreferencesController carried no @copyright

gate-1 (spdx-headers) reported one missing @copyright fleet-wide for this
repo, on lib/Controller/PreferencesController.php. Every other controller
in lib/Controller/ carries the full Conduction tag block plus the two SPDX
identifier lines; this file was written with @author/@license only.

Adds @copyright and the SPDX-FileCopyrightText / SPDX-License-Identifier
lines, matching PolicyController.php verbatim in shape.

* fix(register): drop two $refs OpenRegister can never resolve

gate-54 (relation-dialect) reported:

  correspondence.caseReference — $ref 'case' does not resolve to a schema key
  generatedDocument.zaakId     — $ref 'case' does not resolve to a schema key

Both are correct. There is no `case` schema in DocuDesk's register set, and
there cannot be: both properties reference a Zaak that lives in PROCEST's
register, which is why both already carry `x-external-register: procest`.
OpenRegister resolves `$ref` within one register set, so these two `$ref`s
named a schema the engine could never reach — a relation that only looked like
one. Nothing consumed them as relations either: PdfService reads
`caseReference` as `(string) ($options['caseReference'] ?? '')` and
GeneratedDocumentLogger passes `zaakId` straight through.

Dropping the `$ref` leaves the correct dialect — a bare `format: uuid`
identifier plus `x-external-register` recording where the target lives.

The descriptions were Dutch prose ("UUID verwijzing naar de bron zaak/case in
Procest"); re-authored in English per the fleet rule, and expanded to say why
there is no `$ref`. `Zaak` is a standardised ZGW term and is kept as such —
only the surrounding prose changed, and the property name `zaakId` is
untouched.

NOTE: this alone does not close gate-54. The gate had no concept of a
cross-app reference, so removing the `$ref` merely flipped the finding to the
opposite arm ("relation-shaped property ... lacks canonical $ref"). Both arms
failed — an unclosable gate. Fixed in ConductionNL/.github#286; with that
package this file measures gate-54 PASS.

* fix(security): fail closed when a register/schema binding is unconfigured

gate-50 (security-config-fail-mode): 29 findings -> 0.

Every finding was the same shape: a register/schema binding read with an
empty-string default and passed straight into saveObject()/find(). An
administrator sets these in the admin settings UI and nothing auto-provisions
them, so on an unbound instance DocuDesk wrote to register '' and schema ''
and carried on. For signing that means the audit trail behind an eIDAS-level
signature; for financial extraction the supplier, IBAN, KvK, VAT id and
amounts read off an invoice. Nothing surfaced.

SettingsService now owns the reads, one guarded accessor per binding
(resolveSigningRequestBinding, resolveSignerRecordBinding,
resolveFinancialExtractionBinding, resolveGlAccountBookingBinding,
resolveGlAccountMappingRuleBinding). They return null rather than throwing so
that SettingsService needs no new import — see the coupling note below — and
each consumer decides what "unconfigured" means for it:

* SigningService throws RegisterNotConfiguredException, which
  SigningController already catches to render a calm "not configured" empty
  state. Two further sites gate-50 did NOT flag are converted too: getRequest()
  and the decline path sat inside the gate's 10-line window because of an
  adjacent null-check, but carry the same defect — find() against register ''
  returns null, and both report "not found", so an unconfigured instance
  answered 404 for every signing request that does exist.
* GlAccountSuggestionService lets it propagate; both its controllers catch
  Exception, so it becomes an honest error instead of a wrong answer. "No
  booking history" and "not configured" produced the same empty array, and the
  ranker treats the first as a legitimate cold start — an unbound instance
  silently ranked every supplier as brand new.
* PortalSigningReceiverController denies. This is the anti-IDOR boundary
  (REQ-DDPSA-004) and register/schema are two of the four filters scoping the
  lookup; passing '' made the boundary's correctness depend on OpenRegister
  choosing to match nothing for an empty filter, an assumption this code never
  stated and does not control. The existing catch collapses it to the same null
  the wrong-email and wrong-request cases return, so no new signal is exposed.

SigningVerificationService::getSigningSecret() now returns ?string. Its single
caller already failed closed correctly, 85 lines away and outside the gate's
window, so there was no live defect — but '' is a VALID HMAC key, and a future
caller that forgot the check would verify signatures against a publicly
derivable key and report them genuine. null cannot reach hash_hmac() by
accident.

PHPMD: the guards pushed four metrics over threshold, and no threshold was
changed. SigningService's coupling was resolved by dropping IAppConfig
entirely — its three remaining reads (signing_request_expiry_days,
signing_default_level, signing_provider) duplicated both the keys AND the
defaults that SettingsService::loadFeatureToggles() already owns, two sources
of truth free to drift. SettingsService stays at its previous coupling because
the accessors return null instead of importing the exception type.
GlAccountSuggestionService's class complexity is back under threshold because
the guards moved out of it.

SigningServiceTest stubs the two binding resolvers. That is not incidental: an
unstubbed mock returns null, which is exactly what the fail-closed guard is
there to catch, and 24 of its tests failed until the configured path was
stated explicitly.

* test: stub the new binding resolvers in three suites

Partial. FinancialExtractionServiceTest, GlAccountSuggestionServiceTest and
PortalSigningReceiverControllerTest construct SettingsService mocks that do not
stub the new resolve*Binding() accessors, so they return null and the
fail-closed guard fires. GlAccountSuggestionServiceTest builds three separate
mocks (lines 78, 414, 469) and only two are stubbed; the Portal suite has a
second mock instance still unstubbed. 18 tests remain red for this reason.

* fix(security): route binding resolution through OpenRegisterResolver

Completes the gate-50 fail-closed work.

SettingsService owns the READ and returns null when a binding is unset;
OpenRegisterResolver turns that null into RegisterNotConfiguredException,
alongside the template accessors that already did exactly this. The split
keeps the exception type out of SettingsService, whose object coupling sits at
its PHPMD ceiling, and puts the 'an unset binding is an error' decision in the
class named for it.

GlAccountSuggestionService, FinancialExtractionService and
PortalSigningReceiverController now inject the resolver and no longer inject
IAppConfig at all — every config read they had was a binding read, so the
dependency became unused once those moved.

Two further sites gate-50 did NOT flag are converted for the same reason as
the signing ones: FinancialExtractionService::addCorrection() sat inside the
gate's 10-line window because of an adjacent null-check, but find() against
register '' returns null and it reports 404 'not found' — so an unconfigured
instance denied corrections for extractions that exist.

Tests construct a REAL OpenRegisterResolver over a stubbed SettingsService
rather than mocking the resolver: the resolver is the piece that turns an
unset binding into the exception, so mocking it would remove the behaviour
under test. 1136 tests pass; composer check:strict passes.

* docs: these accessors return null; the caller is what fails closed

* test: cover the fail-closed arm of every register/schema binding

The coverage ratchet caught this: the change added 48 statements and dropped
coverage 61.53% -> 61.39% against the merge base. The uncovered statements were
the ones that matter most — the arm that fires when a binding is UNSET, which
is precisely the path that had never been exercised before this work.

23 tests, data-driven over all five bindings and all four resolver methods:

* each SettingsService::resolve*Binding() returns the configured pair;
* each returns NULL when the register half is unset;
* each returns NULL when the schema half is unset (both halves are required —
  a pair containing '' is what silently wrote to register '');
* each OpenRegisterResolver::get*() returns the binding when configured;
* each THROWS RegisterNotConfiguredException when it is not.

Verified to fail: replacing one throw with a `['register' => '', 'schema' => '']`
fallback produced exactly one failure — testResolverThrowsWhenTheBindingIsUnset
with data set "glAccountBooking" — and nothing else. A test that only proved the
happy path would have passed against the old code too, which is the failure mode
this whole change exists to remove.
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