diff --git a/hydra-gates/scripts/lib/check_system_elevation.py b/hydra-gates/scripts/lib/check_system_elevation.py new file mode 100644 index 00000000..7a7ef59b --- /dev/null +++ b/hydra-gates/scripts/lib/check_system_elevation.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +"""Gate 96 — system-elevation-reachability (ADR-099 rule 9). + +`runAsSystem()` / `SystemOperationContext::run()` runs a callable as a +trusted userless principal: no RBAC, no tenancy, no owner. It exists for +work that genuinely has nobody to act for — an installation seeding its own +shipped registers, a migration, a repair step. A schema migration runs on +nobody's behalf, and pretending otherwise would mean inventing a user. + +WHAT THIS GATE IS ACTUALLY DEFENDING AGAINST + +Not somebody arguing for an escalation. The failure mode is somebody +reaching for the nearest thing that makes a refusal go away. + +That reach is predictable, because ADR-099 put refusals everywhere an +identity can be missing: a schedule trigger that names nobody is refused, a +delegation without a grant is refused, an agent tool acting for an absent +user is refused. Every one of those refusals sits within a few lines of a +method that would make it succeed. A developer under time pressure, staring +at "this flow run has no owner", finds `runAsSystem()` in the same service +they already have injected — and the fix works, the test goes green, and +the run now executes with every access control switched off, permanently, +for every future run of that flow. + +Nothing downstream can catch it. By the time the callable executes, the +caller is gone; there is no runtime assertion the method can make about who +invoked it. So the control has to be structural, and structural controls +drift unless something checks them. + +THE THREE FORBIDDEN CALLERS, AND WHY THOSE THREE + +ADR-099 names flow nodes, agent tools, and inbound request handling. They +are not arbitrary — each carries USER-AUTHORED DEFINITIONS or +USER-SUPPLIED INPUT across the boundary: + + * a flow node executes a graph somebody drew in a browser; + * an agent tool executes a call a model chose, from text it was given; + * a controller executes a request a client sent. + +Elevation reachable from any of them converts "a user can describe work" +into "a user can describe work that runs as root". The other direction — +elevation in a migration — has no user in the picture at all, which is the +whole distinction. + +WHY THERE IS NO EXCLUSION ANNOTATION + +Most gates in this suite take a reason-bearing `@gate exclude `. +This one deliberately does not, and that is the point rather than an +omission. + +An escape hatch on this rule would be used exactly when somebody is trying +to make a refusal go away — the case the gate exists for — and a +reason-bearing comment written in that moment ("needed for the migration +path") is indistinguishable from a legitimate one to every reviewer who +reads it afterwards. Green bought with a plausible sentence is worse than +red, because it ends the conversation. + +If this gate fires on something that is genuinely legitimate, the answer is +to move the elevation OUT of the forbidden caller — into a repair step, a +migration, or a service the caller invokes without passing user input to it +— or to fix this gate. Both leave a visible diff. A comment does not. + +WHAT IT CANNOT SEE + +A dynamically dispatched call (`$svc->{$method}()`, a callable stored in a +variable, a container lookup by string) is invisible to it, exactly as it +is to the PHPUnit boundary test this generalises. It is a guard against +DRIFT, not a proof of absence — and it is stated here so nobody reads a +green as the stronger claim. The control that actually holds the line is +that the elevating service is not injected into node, tool or endpoint +classes. + +FULL-TREE, deliberately NOT diff-scoped. A diff-scoped version reports +nothing on the ~99% of PRs that never open a node or a controller, so it +could not establish that the boundary holds — which is the only claim worth +making about a boundary. The finding set is small enough that noise is not +a risk: on a clean repo it is empty. + +Exit codes: 0 clean · 1 findings · 4 no PHP under lib/ in this repo. +""" +import os +import re +import sys + +# The calls that elevate. Matched as method calls / static calls rather than +# as bare words, so a docblock sentence about `runAsSystem` does not fire — +# comments and strings are masked out anyway, but two independent guards +# against a false positive are cheap and a false positive on this gate is +# what would get it switched off. +ELEVATION_RX = re.compile( + r'(?:->\s*runAsSystem\s*\(|SystemOperationContext\s*::\s*run\s*\()' +) + +# Directory prefixes a call may NOT appear under, and the reason each is +# forbidden. The reason travels into the failure message: "forbidden" alone +# tells a developer they are blocked, not what to do instead. +FORBIDDEN = ( + ( + 'lib/Service/Flow/Nodes/', + 'a flow node executes a graph a user drew, so elevation here lets a ' + 'user describe work that runs with every access control off', + ), + ( + 'lib/Flow/', + 'a flow node executes a graph a user drew, so elevation here lets a ' + 'user describe work that runs with every access control off', + ), + ( + 'lib/Controller/', + 'a controller handles an inbound request, so elevation here runs ' + 'caller-supplied input as a trusted userless principal', + ), + ( + 'lib/Service/Mcp/', + 'an agent tool executes a call a model chose from text it was given, ' + 'so elevation here is reachable from a document', + ), + ( + 'lib/Mcp/', + 'an agent tool executes a call a model chose from text it was given, ' + 'so elevation here is reachable from a document', + ), + ( + 'lib/Tool/', + 'an agent tool executes a call a model chose from text it was given, ' + 'so elevation here is reachable from a document', + ), + ( + 'lib/Tools/', + 'an agent tool executes a call a model chose from text it was given, ' + 'so elevation here is reachable from a document', + ), +) + +# Where a legitimate elevation lives. Not an allowlist of files — an +# allowlist of KINDS, so a new repair step needs no gate change while a new +# controller still fails. Work in these places has no user to act for by +# construction: nobody is present during a migration. +PERMITTED = ( + 'lib/Migration/', + 'lib/Repair/', + 'lib/Command/', + 'lib/BackgroundJob/', + 'lib/Cron/', +) + + +def _mask(src: str) -> str: + """Blank out comments and string literals, preserving line structure. + + A gate that reads raw text reports the sentence describing the rule as a + violation of it — this file's own docblock would fail this gate. Newlines + survive so reported line numbers stay true. + """ + out = [] + i = 0 + n = len(src) + while i < n: + ch = src[i] + nxt = src[i + 1] if (i + 1) < n else '' + if ch == '/' and nxt == '/': + while i < n and src[i] != '\n': + out.append(' ') + i += 1 + continue + if ch == '#' and nxt != '[': + # `#[Attribute]` is code; `# comment` is not. + while i < n and src[i] != '\n': + out.append(' ') + i += 1 + continue + if ch == '/' and nxt == '*': + while i < n and not (src[i] == '*' and (i + 1) < n and src[i + 1] == '/'): + out.append('\n' if src[i] == '\n' else ' ') + i += 1 + out.append(' ') + i += 2 + continue + if ch in ('"', "'"): + quote = ch + out.append(' ') + i += 1 + while i < n and src[i] != quote: + if src[i] == '\\': + out.append(' ') + i += 1 + if i < n: + out.append('\n' if src[i] == '\n' else ' ') + i += 1 + continue + out.append('\n' if src[i] == '\n' else ' ') + i += 1 + out.append(' ') + i += 1 + continue + out.append(ch) + i += 1 + return ''.join(out) + + +def _php_files(root: str): + """Every tracked-looking PHP file under lib/, sorted for a stable report.""" + lib = os.path.join(root, 'lib') + if not os.path.isdir(lib): + return [] + + found = [] + for dirpath, dirnames, filenames in os.walk(lib): + dirnames[:] = [d for d in dirnames if d not in ('vendor', 'node_modules')] + for name in filenames: + if name.endswith('.php'): + full = os.path.join(dirpath, name) + found.append(os.path.relpath(full, root).replace(os.sep, '/')) + return sorted(found) + + +def _forbidden_reason(rel: str): + """Why this path may not elevate, or None when it may.""" + for prefix, why in FORBIDDEN: + if rel.startswith(prefix): + return why + return None + + +def main() -> int: + root = sys.argv[1] if len(sys.argv) > 1 else '.' + files = _php_files(root) + + if not files: + print('checked 0 PHP file(s) under lib/ [full tree]: ' + 'this repo ships no server code that could elevate') + return 4 + + findings = [] + elevating = 0 + + for rel in files: + try: + with open(os.path.join(root, rel), 'r', encoding='utf-8', errors='replace') as handle: + src = handle.read() + except OSError as exc: + # UNREADABLE IS NOT CLEAN. A file that could not be read is a file + # whose elevation is unverified, and reporting it as a pass would + # make the gate's green mean less than it claims. + print(f'FAIL {rel} could not be read ({exc}), so its elevation is UNVERIFIED.') + findings.append(rel) + continue + + masked = _mask(src) + hits = [ + idx + 1 + for idx, line in enumerate(masked.split('\n')) + if ELEVATION_RX.search(line) + ] + if not hits: + continue + + elevating += 1 + why = _forbidden_reason(rel) + if why is None: + continue + + for line_no in hits: + print(f'FAIL {rel}:{line_no} — elevates to a trusted userless ' + f'principal from a forbidden caller.') + print(f' {why}.') + findings.append(rel) + + if findings: + permitted = ', '.join(PERMITTED) + print() + print(' ADR-099 rule 9: elevation is code-initiated only. Move the ' + 'work into a migration, repair step, command or background job ' + f'({permitted}) — or refuse, naming the missing identity. There is ' + 'deliberately no exclusion annotation for this rule: an escape ' + 'hatch would be used exactly when somebody is making a refusal go ' + 'away, which is the case this gate exists for.') + + print(f'\nchecked {len(files)} PHP file(s) under lib/ [full tree]: ' + f'{elevating} elevate, {len(findings)} failure(s)') + return 1 if findings else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh b/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh index 834fe512..b6fdc98a 100755 --- a/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh +++ b/hydra-gates/scripts/lib/test_gate_empty_scope_never_passes.sh @@ -612,7 +612,31 @@ fi # verdict for this gate absent a base showing growth, # which this fixture's docs-only second commit does # not supply for ANY gate. -_ARM6_ALLOWED=" 4 15 16 23 47 48 68 " +# 97 system-elevation-reachability — never diff-scoped, same posture as +# gate-23 and gate-68, and for a reason its own spec +# states: it establishes that a BOUNDARY holds, and a +# boundary cannot be established from a diff. A +# diff-scoped version reports nothing on the ~99% of PRs +# that never open a node or a controller. +# MEASURED, not assumed: `check_system_elevation.py` +# reads NO scope input at all — no BASE_REF, no diff, no +# file list — it walks lib/ itself. On this fixture it +# prints `checked 1 PHP file(s) under lib/ [full tree]: +# 0 elevate, 0 failure(s)` byte-for-byte identically at +# full scope and at --scope-to-diff --base HEAD~1, +# because there is no code path by which the scope could +# reach it. The fixture's ThingController calls +# `findAll()`, which is what gates 14/17/21 plant it for; +# it does not elevate, so a whole-tree read of it is an +# honest zero rather than a scope artifact. +# SEPARATELY: "FAIL the planted tree at full scope" is +# not a reachable verdict for this gate on THIS fixture — +# nothing in it elevates at all — so it is deliberately +# absent from the anti-widening list below. Its +# equivalent lives in gate-acceptance/system-elevation, +# whose planted arm FAILS and whose clean arm PASSES on +# a tree built for it. +_ARM6_ALLOWED=" 4 15 16 23 47 48 68 97 " _wide_bad="" while IFS= read -r _g; do diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 8504cd65..804f6504 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -10814,6 +10814,76 @@ else _fail 96 "manifest-copy-style" "${_mcs_n} manifest string(s) break voice.md section 8 (em-dash / double-dash) - see ${_mcs_log}" fi +# --------------------------------------------------------------------------- +# GATE 97 — system-elevation-reachability (ADR-099 rule 9) +# +# NUMBERED 97, NOT 96. This gate was written as 96 and #581 landed +# `manifest-copy-style` on that number first — two gates, one number, which is +# precisely what gate-95 (adr-number-collision) exists to stop for ADRs. A gate +# id is a citation key in the same way: it appears in COVERAGE lines, in +# `_ARM6_ALLOWED`, in fixture `expect.conf` files and in PR bodies, and each is +# a pointer that only works while the number resolves to one thing. +# +# `runAsSystem()` / `SystemOperationContext::run()` runs a callable as a +# trusted userless principal: no RBAC, no tenancy, no owner. It is for work +# that genuinely has nobody to act for — an installation seeding its own +# shipped registers, a migration, a repair step. It MUST NOT be reachable +# from a flow node, an agent tool, or the handling of an inbound request. +# +# WHAT THIS DEFENDS AGAINST is not somebody arguing for an escalation. It is +# somebody reaching for the nearest thing that makes a refusal go away — +# and ADR-099 deliberately put refusals everywhere an identity can be +# missing, each one within a few lines of a method that would make it +# succeed. The fix works, the test goes green, and every future run of that +# flow executes with access control off. +# +# Nothing downstream can catch it: by the time the callable runs the caller +# is gone, so no runtime assertion inside the method can say who invoked it. +# The control has to be structural, and structural controls drift. +# +# NO EXCLUSION ANNOTATION, deliberately. An escape hatch on this rule would +# be used exactly when somebody is making a refusal go away — the case the +# gate exists for — and a reason written in that moment reads identically to +# a legitimate one afterwards. Green bought with a plausible sentence is +# worse than red, because it ends the conversation. A genuine false positive +# is fixed by moving the elevation out of the forbidden caller, or by fixing +# this gate. Both leave a visible diff; a comment does not. +# +# GENERALISES a PHPUnit test. openregister's SystemOperationContextBoundaryTest +# pins the call-site set in ONE app; this binds the fleet, so an app that +# never wrote such a test is covered too. +# +# FULL-TREE, not diff-scoped: a diff-scoped version reports nothing on the +# ~99% of PRs that never open a node or a controller, so it could not +# establish that the boundary holds — the only claim worth making about a +# boundary. Noise is not a risk; on a clean repo the finding set is empty. +# +# NOTE ON PLACEMENT: top level, outside any `_FAILED` guard — a gate that +# only runs once everything else passed is green-but-dead. +# --------------------------------------------------------------------------- +_sel_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-system-elevation.log +: > "${_sel_log}" +set +e +python3 "${SCRIPT_DIR}/lib/check_system_elevation.py" . > "${_sel_log}" 2>&1 +_sel_rc=$? +# `set +e`, not `set -e`: errexit off is the state this script actually runs +# in. See the note at the top of this file. +set +e + +if [ "${_sel_rc}" -eq 0 ]; then + _pass 97 "system-elevation-reachability" +elif [ "${_sel_rc}" -eq 4 ]; then + _skip 97 "system-elevation-reachability" na "this repo ships no PHP under lib/, so it has no server code that could elevate to a trusted userless principal. See ${_sel_log}." +elif ! _helper_finished "${_sel_log}" '^checked [0-9]+ PHP file'; then + # A CRASH IS NOT A FINDING. + _sel_why=$(head -3 "${_sel_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 97 "system-elevation-reachability" wiring "check_system_elevation.py exited ${_sel_rc} without printing its terminal 'checked N PHP file(s)' summary, so the elevation boundary is UNVERIFIED by this run. Checker output: ${_sel_why:-}. See ${_sel_log}." +else + _sel_n=$(grep -cE '^FAIL ' "${_sel_log}" 2>/dev/null || true) + case "${_sel_n}" in ''|*[!0-9]*) _sel_n=1 ;; esac + _fail 97 "system-elevation-reachability" "${_sel_n} elevation(s) reachable from a flow node, agent tool or endpoint — see ${_sel_log}" +fi + # --------------------------------------------------------------------------- # GATE 83 — contract-surface-shift (ADR-084) # diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.d/steps.json b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.d/steps.json new file mode 100644 index 00000000..59dcbf49 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.d/steps.json @@ -0,0 +1,10 @@ +{ + "pages": [ + { + "id": "intake", + "title": "Intake", + "emptyText": "Nothing here yet. Add the first case to get started.", + "helpText": "Reporting covers 2020–2024." + } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.json b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.json new file mode 100644 index 00000000..1899c06a --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/clean/src/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "fx", + "title": "Case handling", + "description": "Register a case, assign it, close it.", + "menu": [], + "pages": [] +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/expect.conf b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/expect.conf new file mode 100644 index 00000000..5654c6b5 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/expect.conf @@ -0,0 +1,34 @@ +# expect.conf — read by scripts/lib/test_gate_acceptance_matrix.sh +# +# Columns: +# gate +# +# Bundle: manifest-copy-style — gate-96 (voice.md §8, added by #581). +# +# WHY THIS BUNDLE EXISTS. #581 added the gate to the runner and shipped no +# fixture and no UNCOVERED.md row, so the acceptance ratchet went red on `main` +# from the moment it merged — "a gate can be added to the runner and never +# tested; this is that moment", in the suite's own words. The ratchet worked; +# nothing had closed the loop it opened. This is that loop. +# +# THE TWO ARMS DIFFER IN TWO STRINGS, in two different files, on purpose: +# +# * `src/manifest.json` → `title` carries an EM-DASH. +# * `src/manifest.d/steps.json` → `emptyText` carries a DOUBLE-DASH. +# +# Both are independently fatal, so a checker that caught only one still fails +# this arm. Splitting them across the base manifest and a FRAGMENT is the part +# that matters most: fragments are merged at runtime by `require.context`, and a +# checker reading only `src/manifest.json` would be blind to eight fleet apps' +# copy — shillinq alone has 87 fragments. A single-file fixture would have passed +# such a checker and taught us nothing. +# +# THE CLEAN ARM IS NOT MERELY DASH-FREE. It keeps `helpText` reading +# "Reporting covers 2020–2024" — an EN-DASH between digits, which voice.md +# explicitly permits as a numeric range. Without that string, a checker that +# banned every dash character outright would pass the clean arm, and the one +# exception the rule actually has would be untested. +# +# Subject substring is the em-dashed title's own file, so a gate that fails for +# an unrelated reason, or reports a bare count, does not name it. +gate 96 hydra-gate-manifest-copy-style.log FAIL PASS src/manifest.json diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.d/steps.json b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.d/steps.json new file mode 100644 index 00000000..4a35be29 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.d/steps.json @@ -0,0 +1,10 @@ +{ + "pages": [ + { + "id": "intake", + "title": "Intake", + "emptyText": "Nothing here yet -- add the first case to get started.", + "helpText": "Reporting covers 2020–2024." + } + ] +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.json b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.json new file mode 100644 index 00000000..cfb74570 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/manifest-copy-style/planted/src/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "fx", + "title": "Case handling — a quick spin", + "description": "Register a case, assign it, close it.", + "menu": [], + "pages": [] +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Controller/ObjectImportController.php b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Controller/ObjectImportController.php new file mode 100644 index 00000000..8bad1c85 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Controller/ObjectImportController.php @@ -0,0 +1,34 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * CLEAN: the same controller, refusing instead of elevating. + * + * It also MENTIONS the forbidden call twice — once in prose and once in a + * string literal — because a gate that reads raw text reports the sentence + * describing the rule as a violation of it, and this arm is what proves the + * comment/string mask is load-bearing rather than decorative. + */ + +declare(strict_types=1); + +namespace OCA\Fixture\Controller; + +class ObjectImportController { + + /** + * Never calls ->runAsSystem( — an import acts as the person importing. + */ + public function import(array $payload): array { + if ($this->userSession->getUser() === null) { + throw new \RuntimeException( + 'Refusing to import: nothing names who this acts as. ' + . 'Do not reach for ->runAsSystem( here.' + ); + } + + return $payload; + } +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Migration/SeedShippedRegisters.php b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Migration/SeedShippedRegisters.php new file mode 100644 index 00000000..eb112338 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/clean/lib/Migration/SeedShippedRegisters.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * PERMITTED, and present in BOTH arms on purpose. + * + * A migration seeding the app's own shipped registers has no user to act for: + * nobody is present. If the gate flagged this too, the planted arm would pass + * for the wrong reason and the rule would read as "never elevate", which is + * not the rule. + */ + +declare(strict_types=1); + +namespace OCA\Fixture\Migration; + +class SeedShippedRegisters { + + public function run(): void { + $this->objectService->runAsSystem( + static fn (): bool => true + ); + } +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/expect.conf b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/expect.conf new file mode 100644 index 00000000..aa78d519 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/expect.conf @@ -0,0 +1,29 @@ +# expect.conf — read by scripts/lib/test_gate_acceptance_matrix.sh +# +# Columns: +# gate +# +# Bundle: system-elevation — gate-97 (system-elevation-reachability, ADR-099 +# rule 9). +# +# The two arms differ in ONE method body. Both ship the same controller class +# and the same migration; in planted/ the controller elevates, in clean/ it +# refuses and names what is missing instead. +# +# THE MIGRATION IS IN BOTH ARMS, DELIBERATELY. It elevates in both, and must +# be clean in both. Without it the planted arm would pass for a gate that +# banned elevation outright, which is not the rule — the rule is about WHO may +# reach it. A gate that failed the migration too would look identical on the +# planted arm and would be wrong about the only distinction that matters. +# +# THE CLEAN CONTROLLER MENTIONS THE FORBIDDEN CALL TWICE — once in a docblock +# sentence, once inside a string literal in the refusal message it throws. +# That is what makes this arm load-bearing rather than trivially green: a +# checker written as a raw-text grep reports the sentence describing the rule +# as a violation of it, and would fail this arm. It is the same defect gate-2 +# was rewritten for after `"select dd(x)"` and a comment saying "never use +# var_dump( here" were both reported as findings. +# +# Subject substring is the controller's path: a gate that fails for an +# unrelated reason, or reports a bare count, does not name it. +gate 97 hydra-gate-system-elevation.log FAIL PASS lib/Controller/ObjectImportController.php diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Controller/ObjectImportController.php b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Controller/ObjectImportController.php new file mode 100644 index 00000000..bd0ddf76 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Controller/ObjectImportController.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * PLANTED: elevates from a controller. + * + * The shape this gate exists for. The author hit "this request has no owner", + * found the elevating method on a service that was already injected, and the + * refusal went away — so every import now runs with RBAC and tenancy off, on + * a payload the caller supplied. + */ + +declare(strict_types=1); + +namespace OCA\Fixture\Controller; + +class ObjectImportController { + + public function import(array $payload): array { + return $this->objectService->runAsSystem( + static fn (): array => $payload + ); + } +} diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Migration/SeedShippedRegisters.php b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Migration/SeedShippedRegisters.php new file mode 100644 index 00000000..eb112338 --- /dev/null +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/system-elevation/planted/lib/Migration/SeedShippedRegisters.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * PERMITTED, and present in BOTH arms on purpose. + * + * A migration seeding the app's own shipped registers has no user to act for: + * nobody is present. If the gate flagged this too, the planted arm would pass + * for the wrong reason and the rule would read as "never elevate", which is + * not the rule. + */ + +declare(strict_types=1); + +namespace OCA\Fixture\Migration; + +class SeedShippedRegisters { + + public function run(): void { + $this->objectService->runAsSystem( + static fn (): bool => true + ); + } +}