Skip to content

Land the managed-engines work (#39 to #45) - #47

Merged
imanimanyara merged 44 commits into
mainfrom
land/managed-engines
Oct 17, 2026
Merged

imanimanyara merged 44 commits into
mainfrom
land/managed-engines

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

Lands #39, #40, #41, #42, #43, #44 and #45 together, one merge commit per branch, so each of those pull requests shows as merged when this one lands. #46 (Release 0.1.1) is rebuilt on top of this afterwards.

Conflicts resolved

Every conflict was additive: both sides were kept. Three needed more than concatenation:

Other joins:

  • DoctorService takes the hosting, operator-security and tunnel checks.
  • ServerDefinition takes both hosting and ssh.
  • The backup service skips the dump on managed hosting before it opens a tunnel.
  • The Unreleased CHANGELOG sections are folded into one of each, with no entry lost.

Verified locally

  • pest: 745 passed, 74 skipped (live groups).
  • laranail-pint --test, phpstan (--memory-limit=2G) and rector: all clean.

The live jobs run here.

… empty

isEmpty() counted information_schema.TABLES alone, so a database holding
only a stored procedure or an event read as empty, and a wizard rollback
dropped it with them. The MySQL family now implements
EmptinessProbingEngine and counts TABLES, ROUTINES, EVENTS and TRIGGERS,
and DatabaseManager no longer builds SQL for it.
…, end drop-blocking sessions

- Grants, revokes and the privilege check cover every user schema the
  admin can grant on, not only public. Default privileges are set
  database-wide (no IN SCHEMA), plus ON SCHEMAS, so schemas and objects
  created later are covered. Revokes also clear the per-schema defaults
  earlier releases set.
- A default privilege belongs to the role that creates the object, so a
  reader missed what the application's own role created later. A grant
  now sets defaults FOR ROLE each role that can create objects here, and
  a writer granted after the readers inherits the admin's database-wide
  defaults, which list every role db-console granted in that database.
- Granting on objects another role owns, and FOR ROLE defaults, need
  that role's privileges. RoleMembership takes membership in the roles
  OwnerDelegatingEngine::rolesToAssume() lists and gives it back
  afterwards; a role it cannot take is reported in the result.
- DROP DATABASE ... WITH (FORCE) ends only sessions the admin may end.
  The drop reads whose sessions they are (SessionEndingEngine), refuses
  up front for a superuser's or an unreachable role's, and otherwise
  takes membership so FORCE can end them.
Backup-before-drop runs mysqldump as the recommended admin, never root,
and had never run against a real server. The live-mysql job now runs
MySQL with GTID on, as DigitalOcean does, installs each family's own dump
client, and drops a non-empty database with backups on, then restores
the dump through db-tools as the same admin. db-tools is required at
^0.1.6, whose backups are safe on managed servers.

Also documents the Phase A changes in CHANGELOG, UPGRADING and the
engines reference.
AccountManager::create() and DatabaseManager::create() adopted a resource
that was already there without saying so, and an account's supplied
password was dropped. Both now go through one ExistingResourceGuard and
an ExistingResourcePolicy resolved per call, then per server, then
globally, defaulting to refuse.

refuse throws AccountAlreadyExists / DatabaseAlreadyExists (409) and
reports to the security recipients. adopt needs the new resource.adopt
permission, held by Owner only. adopt_and_apply also needs account.rotate
and a StepUpVerifier pass (null driver for now). The wizard checks the
right to apply before its grant and applies the password last, and a
rollback still never drops what was adopted.
A server can now say where it runs: self_hosted (the default, so existing configs are unchanged), digitalocean, rds, cloud_sql or generic. Hosting::profile() turns that into what DBConsole may assume on the engine: which accounts the provider owns, which host a new MySQL account gets, and whether the provider's point-in-time recovery is the backup. ServerRegistry::capabilities() merges it into the engine's Capabilities. An unknown value fails rather than quietly becoming self-hosted.
On a managed hosting, AccountManager::list() leaves out the provider's and the engine's own accounts, user:create defaults a MySQL account's host to %, and the drop logs a restore point instead of dumping (backup.dump_on_managed keeps dumping). Self-hosted servers behave as before.
Doctor warns when a self-hosted server's host belongs to DigitalOcean or RDS, when a MySQL admin cannot read mysql.user, and when a Postgres admin lacks pg_signal_backend for a forced drop, and reports sql_require_primary_key and gtid_mode. The live MySQL and Postgres groups assert the probes against real servers.
… API

user:create, db:create and wizard take --on-existing. Interactive and
unset, they ask what should happen to the account or database that is
already there, naming each option's risk, with the configured policy as
the default. --no-interaction uses the configured policy. A refusal names
the --on-existing values the operator may use, and none when they may
not adopt. CreateAccountRequest and CreateDatabaseRequest validate an
on_existing field.
ApiGuard's user resolver called itself, so every authenticated API
request recursed until the stack ran out, and it never made the API guard
the default, so the services' Gate saw a sanctum caller as nobody. It now
binds the user it resolved and calls shouldUse(), as Laravel's
Authenticate middleware does.

The code read api.guard and api.prefix while the config defined api.auth
and api.path. ApiSettings reads the documented keys and falls back to the
old ones with a deprecation notice. api.require_https and
api.confirm_destructive are read (defaults unchanged), and api.rate_limit
is applied as the laranail-db-console.api limiter, keyed on the caller.
Adopted resources answer 200 rather than 201.
… hours

notifications.channels lists mail, slack and database per category.
Slack goes to notifications.slack.route (laravel/slack-notification-
channel, suggested): a webhook URL or a bot channel. The database channel
goes to notifications.database.users. Warning and Critical events use
alerts.channel instead of their category's channels.

notifications.queue and quiet_hours were configured and never read. The
first now queues (true, a queue name, or false to send in the request),
and quiet hours hold routine notifications until the window ends, never
destructive or security ones.
Refuse, adopt, adopt_and_apply and a wizard re-run against real servers,
judged by logging in with each password rather than by what db-console
recorded. The two MySQL rollback tests that relied on silent adoption
now ask for it.
CHANGELOG, UPGRADING (the refusal, re-seeding resource.adopt, queued
notifications, the API now reading api.auth and api.path), a recipe for
adopting an existing account or database, and the configuration, API,
commands, RBAC and events pages. Suggest laravel/slack-notification-
channel.
Operators without confirmed two-factor and recovery codes are refused in
production by default (security.two_factor.enforce, report elsewhere),
checked in the Authorizer after the gate allows and in the API guard.

Lockouts are designed out: a read-only enrolment grace window counted from
the first refusal, recovery codes required for enrolment to count, a
shell-only break-glass command, refusal to strand the last enrolled Owner,
and a report mode that alerts what require would refuse.

Dropping databases and accounts, rotating passwords and secrets, changing
an account's host and regenerating recovery codes need step-up: the
password.confirm timestamp (password or passkey via authkit-preset), or a
one-time code verified by laranail/authkit. doctor reports all of it.
…river

Authentication stays with the host. Contracts\OperatorAssurance
(hasSecondFactor, recentlyConfirmedAt) is what it binds from its own
two-factor and confirmation records. adopt_and_apply needs a confirmation
within security.step_up.ttl seconds (default 300), and is refused with
ConfirmationRequired (403, confirmation.required) otherwise, with no
operator, or under the default NullOperatorAssurance. StepUpVerifier,
added earlier on this branch and never released, is gone.

Test passwords are generated per run rather than written as literals.
…aults

- The live MySQL backup test resolved db-tools' BackupManagerInterface
  without its provider: an application discovers it, Testbench only
  registers what TestCase names, so the backup threw BackupFailed.
- The Postgres grant listing now shows the role's default privileges as
  database-wide, since grants stopped setting them per schema.
The live-tls job ran a plaintext PostgreSQL and a MySQL whose TLS was
optional, so nothing showed what doctor reports against a managed
database that refuses plaintext. It now runs MySQL with
require_secure_transport=ON and PostgreSQL with ssl=on and hostssl-only
pg_hba, on a CA generated in the job, as restricted admins, beside a
plaintext MySQL and PostgreSQL for the guard. Every refusal is paired
with a connection the same server accepts, so a server that is down
fails the group rather than passing it.
…arts

- On PG15 schema public belongs to pg_database_owner, a predefined role
  that cannot have explicit members. Before PG16 a CREATEROLE admin looks
  able to take any non-superuser role, so the grant tried to take it and
  failed. Roles named pg_* are never taken or set defaults FOR.
- MySQL 9 does not start with gtid_mode OFF, and the mode changes one
  step at a time, so the CI step now walks forward from the current mode.
caching_sha2_password will not authenticate over plaintext unless the
client asks for the server's public key, so the readiness ping never
passed against the MySQL with TLS switched off.
db-console no longer verifies codes, reads Fortify or authkit columns, runs
an enrolment grace window or manages recovery codes. The host implements
Contracts\OperatorAssurance (hasSecondFactor, recentlyConfirmedAt), and
the Authorizer checks its answers against security.assurance on top of
RBAC: a second factor for writes, and a recent confirmation for drops,
rotations, host changes and secrets rotation.

security.assurance.enforce keeps the off/report/require rollout, defaulting
to require in production. A refusal is AssuranceRequired (403,
assurance_required, with the missing list). The shell-only break-glass
command and the last-Owner guard stay, now keyed to the host's answer.
The MySQL legs run with sql_require_primary_key=ON.
The live run showed the enforcing PostgreSQL accepting tls.enabled=false:
the connection keeps libpq's default sslmode=prefer, which takes the TLS
the server offers, so doctor warns that nothing requires it rather than
erroring. Pin that, and ask for sslmode=disable where a plaintext session
is what is under test.
Each live-tls test is fed the configuration that should make its
assertion fail, with its control left alone. Expected: every test but
the config-only locality check fails.
Restores the live-tls tests and runs MySQL without
require_secure_transport and PostgreSQL with a pg_hba that accepts
plaintext. Expected: the three refusal tests fail, and
LiveTlsWorkflowTest fails in the pest matrix.
Restores require_secure_transport=ON and the hostssl-only pg_hba. The
tree is now identical to the last green commit before the teeth checks.
Reverting the second teeth commit also re-applied the first one's
inverted inputs. The tree is now identical to 61f2f74, the last green
commit before the teeth checks.
A server's ssh block routes its admin connection through a tunnel behind
the TunnelDriver contract. openssh runs ssh -N -L with BatchMode, a
generated known_hosts and StrictHostKeyChecking=yes; phpseclib forwards
from a child PHP process, since the request using the tunnel is blocked
in PDO. Both refuse any host key but the pinned one, before offering a
credential. A key from the secrets driver lives in a 0600 file inside a
0700 directory for the life of the tunnel.

ServerRegistry opens the tunnel on first use, rewrites the connection to
its local end (a loopback port, or a Unix socket), reopens it if it dies,
and restores the connection when the application terminates. TLS still
applies end to end; Postgres under verify-full dials the forward through
libpq's hostaddr so the certificate is still checked against the real
name. A socket local end that would leave a networked onward hop in
plaintext while TLS is required is refused.

TlsChecker no longer reads a tunnelled server's 127.0.0.1 as local, and
classes direct Unix socket connections as local.
Runs before the reachability round trip, which then goes through the tunnel. A tunnel error stops doctor for that server, so the report names the tunnel rather than a vaguer unreachable.
Reading the admin connection before anything opened the server's tunnel would dump straight to the remote host. db-tools reads socket where Laravel's key is unix_socket, so mysqldump ignored a configured socket.
An OpenSSH server container on the service network, sharing both databases' socket directories: both drivers over TCP and remote sockets, a socket local end, two sessions through one tunnel, TLS measured inside it, doctor, and an unrelated host key that must be refused.
…nd/managed-engines

# Conflicts:
#	CHANGELOG.md
#	src/Engines/MySqlEngine.php
…managed-engines

# Conflicts:
#	CHANGELOG.md
#	UPGRADING.md
#	tests/Feature/Engines/LivePostgresProvisioningTest.php
…nto land/managed-engines

# Conflicts:
#	CHANGELOG.md
#	UPGRADING.md
#	config/db-console.php
#	src/Console/Commands/UserCreateCommand.php
#	src/Services/DatabaseManager.php
#	tests/Feature/Engines/LiveMySqlProvisioningTest.php
#	tests/Feature/Engines/LivePostgresProvisioningTest.php
…nd/managed-engines

# Conflicts:
#	CHANGELOG.md
#	UPGRADING.md
#	docs/configuration.md
#	resources/lang/en/exceptions.php
#	src/Contracts/OperatorAssurance.php
#	src/Doctor/DoctorService.php
#	src/Enums/ExceptionCode.php
#	src/Http/Middleware/ApiGuard.php
#	src/Providers/DBConsoleServiceProvider.php
#	src/Security/NullOperatorAssurance.php
#	tests/Unit/Enums/EnumsTest.php
…ged-engines

# Conflicts:
#	CHANGELOG.md
#	README.md
#	UPGRADING.md
#	composer.json
#	config/db-console.php
#	src/Backup/DbToolsBackupService.php
#	src/Doctor/DoctorService.php
#	src/Providers/DBConsoleServiceProvider.php
#	src/Servers/ServerDefinition.php
#	src/Servers/ServerRegistry.php
#	tests/Feature/Backup/BackupBeforeDropTest.php
#	tests/Unit/Enums/EnumsTest.php
…o land/managed-engines

# Conflicts:
#	CHANGELOG.md
Landing seven branches together left two Added sections under
Unreleased. Every entry is kept; the sections now run in Keep a
Changelog order.
A PHP array literal keeps the last of two equal keys without a word.
Landing the managed-engines branches together left two top-level
'security' blocks, and the second, holding only step_up, discarded every
operator-assurance setting. The test reads the file's tokens, so it sees
the key the parsed array has already thrown away.
Copilot AI balanced review requested due to automatic review settings October 17, 2026 21:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants