Skip to content

test(security): pin tenant route permissions and audit every operator user change - #22

Merged
knittingcode merged 11 commits into
mainfrom
test/fcrm-auto-audit-permissions
Oct 25, 2026
Merged

knittingcode merged 11 commits into
mainfrom
test/fcrm-auto-audit-permissions

Conversation

@knittingcode

@knittingcode knittingcode commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds tests and negative controls to main for protections that had none. No production code changes.

Motivation

Read-only gap analyses of the security tests found regressions that no test on main would catch:

  1. A weakened route permission. Changing a tenant route's #[TenantPermission] to a weaker permission passed every test.
  2. Missing or non-atomic audit writes for operator commands. For example, removing the audit write from flowcrm:user:enable, or enabling outside the audit transaction, passed every test.
  3. Log redaction and pseudonyms (fix(logging): remove query strings from logged URLs #16, fix(security): share the login limits across every spelling of an account #23). A URL key that kept its fragment when the URL had no query, and a failed-login pseudonym computed with a different case folding than the account lookup, both passed every test. Several other protections were tested but had no negative control: the referer key, the values of sensitive keys, database driver messages, and the member list order and total.

Changes

  • RouteSecurityTest::testEveryTenantRouteDeclaresItsPinnedPermission: pins the exact permission of every tenant route on main:

    • api_v1_organization → organization.view;
    • api_v1_organization_member and api_v1_organization_members → membership.list.

    Adding a tenant route or changing its permission fails the test, so the map must be updated on purpose.

  • AuditTrailTest::testOperatorUserChangesAreAuditedWithTheirAction: disable, enable and reset-password each write user.disabled, user.enabled and user.password_reset. The test checks:

    • actor platform_operator;
    • no actor user, role, organization or IP;
    • empty changes;
    • no password, e-mail or hash in the event.
  • AuditTrailTest::testOperatorEnableAndProvisioningWhoseAuditEventCannotBeWrittenChangeNothing: while the audit write fails, these commands store nothing:

    • flowcrm:user:enable;
    • flowcrm:user:create;
    • flowcrm:organization:create;
    • flowcrm:membership:add.
  • The existing helper whileAuditFailsFor() now delegates to a new whileAuditFails($when), with no change in behaviour.

  • SecurityLoggingTest::testFailedLoginsOfOneAccountShareOnePseudonymWhateverTheSpelling: two failed logins, one with the address as stored and one upper-cased with U+0130, must log the same account_ref. The lookup folds both to the same account, so the log must too.

  • JsonLineFormatterTest::testEveryUrlKeyLosesTheFragmentOfAUrlWithoutAQuery: /accounts#search=… under every URL key must lose the fragment.

  • Ten new negative controls in controls.php, each next to the related entries:

    • a tenant route weakened to another permission;
    • enabling a user without an audit event;
    • a user enabled outside the audit transaction;
    • URL keys keep their fragment;
    • referer not treated as a URL key;
    • values of sensitive keys logged;
    • database driver messages logged;
    • failed-login pseudonym folds case unlike the user lookup;
    • member list ordered by id only;
    • member list total counts removed members.
  • Docs: the BE-18 control count is 48 (main's 38 plus these 10) in authentication.md, ADR-019 and dev-environment.md, which still said 28. The authentication guide's test table names the pinned permissions, the operator commands checked with a failing audit write, and the pseudonym folding. The observability test table names the fragment case.

Testing

  • Local, in a disposable Compose project with real PostgreSQL and PHP-FPM:
    • RouteSecurity, AuditTrail, TenantAuthorization and Authentication tests: 41 tests OK.
    • With the permission control applied, the full suite has exactly 1 failure, the new test. Before this change, that weakening went unnoticed.
    • At head d3f93f3:
      • integration suite: OK (274 tests, 2466 assertions);
      • SecurityLoggingTest|JsonLineFormatterTest: OK (68 tests, 479 assertions);
      • run-controls.php: 48 of 48 negative controls detected.
    • The fragment and pseudonym controls were first checked against the code without the new tests, in an independent review's scratch tree, and went undetected there. The tests close those two gaps.
  • Unit tests, PHPStan, PHP-CS-Fixer, Deptrac and the architecture tests are clean.
  • CI on the exact head is the reference.

Interaction with other PRs

Known limitations

  • While active, two failing-audit triggers apply to the whole database, because new IDs are unknown in advance. Integration tests run serially, so this is safe today.

RouteSecurityTest only required exactly one TenantPermission per tenant
route, so changing a route to another permission that every role holds
today passed every test. The new test pins route name to permission over
the whole tenant route set, and a negative control weakens the member
detail route from membership.list to organization.view.
…without their events

user.disabled, user.enabled and user.password_reset were never asserted by
action name, and enabling, user creation, organization creation and the
operator membership add had no audit-failure test. AuditTrailTest now checks
the action, the operator actor and the absence of secrets for the three user
changes, and that each of the other commands stores nothing while its audit
event cannot be written. Two negative controls remove the user.enabled audit
write and move enabling out of the audit transaction.
Main has 38 controls with #23 and #16; this branch adds three, so the
documented count is 41.
The authentication guide's test table now names the per-route permission
pin of RouteSecurityTest and the operator commands that AuditTrailTest
checks with a failing audit write.
…controls

Seven negative controls for protections on main that no control covered: URL fragments under URL keys, the referer key, values of sensitive keys, database driver messages, the failed-login pseudonym folding (#23), and the member list order and total. Two tests close the gaps the fragment and pseudonym controls exposed. The documented count becomes 48.
The documented negative-control count is 50 (38 + #28's 2 + this branch's 10), and the authentication test table keeps both the pinned route permissions and #28's console input rule.
Only the documented negative-control count conflicted: 53 with #26's three controls.
@knittingcode
knittingcode merged commit 5e4a150 into main Oct 25, 2026
2 checks passed
@knittingcode
knittingcode deleted the test/fcrm-auto-audit-permissions branch October 25, 2026 18:54
knittingcode added a commit that referenced this pull request Oct 25, 2026
tests/devenv/unit.sh keeps both new blocks: main's U-25 (login check of the stack suite) and this branch's U-26 (image removal by name).
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