Repository navigation
Land the managed-engines work (#39 to #45) - #47
Merged
Merged
Conversation
… 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.
# Conflicts: # CHANGELOG.md # UPGRADING.md
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.
This was referenced Oct 17, 2026
Merged
imanimanyara
added a commit
that referenced
this pull request
Oct 17, 2026
…nd trip countUserObjects() counts tables, routines, events and triggers since #47, as the section already says; the old paragraph said information_schema.tables only.
This was referenced Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
config/db-console.phphad two top-level'security'keys after Refuse existing accounts and databases unless adopted; alerts that reach someone #42 and Enforce host-reported operator assurance on top of RBAC #43 met. PHP keeps the last, so Refuse existing accounts and databases unless adopted; alerts that reach someone #42'sstep_up-only block silently replaced Enforce host-reported operator assurance on top of RBAC #43'sassurancesettings, and 10 assurance tests failed. They are now one block.tests/Unit/ConfigKeysTest.phpnow fails on any key written twice in the same array. It reads tokens, not the parsed array, and it was proven red on a reintroduced duplicate.Contracts\OperatorAssurance, with the same two methods (Refuse existing accounts and databases unless adopted; alerts that reach someone #42 had borrowed it). This keeps Enforce host-reported operator assurance on top of RBAC #43's docblocks and both exception families:ConfirmationRequired(Refuse existing accounts and databases unless adopted; alerts that reach someone #42) andAssuranceRequired/OwnerWouldBeStranded(Enforce host-reported operator assurance on top of RBAC #43), 25 codes in all.ExistingResourcePolicyTestpinned onerecentlyConfirmedAt()call. With both features in, it is two:AssuranceEnforcerandExistingResourceGuard. The assertion stays exact,['7', '7'], with a comment naming both callers.ApiGuardkeeps Refuse existing accounts and databases unless adopted; alerts that reach someone #42's resolver, which returns the user it already resolved. Enforce host-reported operator assurance on top of RBAC #43's form called$request->user()again, which Refuse existing accounts and databases unless adopted; alerts that reach someone #42's comment records as recursing until the stack ran out. It also keeps Enforce host-reported operator assurance on top of RBAC #43'sAuthenticatablecheck.Other joins:
DoctorServicetakes the hosting, operator-security and tunnel checks.ServerDefinitiontakes bothhostingandssh.UnreleasedCHANGELOG sections are folded into one of each, with no entry lost.Verified locally
laranail-pint --test, phpstan (--memory-limit=2G) and rector: all clean.The live jobs run here.