Files
projectsend/app/Modules/Platform/Settings/Console/SeedSettingsCommand.php
ignacionelson ac691387e8 Seed two-factor enforcement at provision, before the first account exists
The last of the three. Enforcement is a database setting defaulting to
'none', and on a managed installation the only writers are whoever
administers it and the boot that creates them — so a policy meant to be
on from the start had nowhere to be written. A control plane calling in
afterwards leaves a window between the first account existing and the
policy covering it, and the first account is the one with every
permission.

The entrypoint already seeds an account from the environment. This seeds
the policy one line above it, so the administrator is born under the rule
rather than ahead of it. There is a test for exactly that ordering,
because the ordering is the whole point.

Seeded, never overridden. A value that won on every boot would take the
setting away from the person it belongs to — somebody who tightened it
would find it loosened again by a restart. So it writes only when nothing
has ever been stored, the same shape as `projectsend:admin --if-none`.

Two things that would have been easy to get wrong, both pinned:

'none' is the enum's own default, so Settings::get() cannot tell "stored
as none" from "never stored". Asking the accessor would have overwritten
an administrator who deliberately chose it. The command asks the table.

And it reads config rather than env() directly. `config:cache` stops .env
being read at all, which is how TRUSTED_PROXIES came to have no effect on
any web request while looking correct in the file.

Deliberately not a general PROJECTSEND_SETTING_<KEY> mechanism. Every
setting reachable from outside is one whose value depends on where you
look, and the blast radius of getting that wrong is the settings table.
One named key per setting that needs it.

The three new variables are documented in config/projectsend.php and not
in .env.example or the Docker Hub overview. Those two are written for
somebody running one installation for themselves, and a seat cap is not
a thing they have — FILES_WEB_SERVER_READABLE is in .env.example because
a self-hoster on cPanel genuinely meets that problem.
2026-08-27 02:38:39 -03:00

90 lines
3.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings\Console;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Settings\StoredSetting;
use Illuminate\Console\Command;
/**
* Seed a setting from the environment, once, at provision.
*
* Settings live in the database because they belong to whoever runs the
* installation. A managed one has a moment before anybody runs it — the
* first boot, where the entrypoint already creates the first administrator
* from ADMIN_* — and a policy that has to exist before the account it
* protects has to be written there or not at all.
*
* The case this exists for is two-factor enforcement. A platform wants it
* on before the first seat, and the first seat is created in that same
* boot. Left to a control plane calling in afterwards, there is a window
* between the account existing and the policy covering it.
*
* ### Seeded, not overridden
*
* Writing only when nothing has been stored is the whole design. A value
* that won on every boot would take the setting away from the
* administrator it belongs to, and somebody who tightened it would find it
* loosened again by a restart. Same shape as `projectsend:admin --if-none`,
* which runs a line below this one in the entrypoint.
*
* ### Read through config, never env() directly
*
* `config:cache` stops `.env` being read at all, which is exactly how
* TRUSTED_PROXIES came to have no effect on any web request while looking
* correct in the file. Anything an operator sets has to arrive through a
* config key or it works until somebody optimises the install.
*
* ### Deliberately not general
*
* No `PROJECTSEND_SETTING_<KEY>` mechanism. Every setting reachable from
* outside is a setting whose value depends on where you look, and the
* blast radius of getting that wrong is the whole settings table. One
* named key per setting that needs it, added when it needs it.
*/
class SeedSettingsCommand extends Command
{
protected $signature = 'projectsend:seed-settings';
protected $description = 'Apply provisioning defaults from the environment to settings that have never been set';
public function handle(Settings $settings): int
{
$enforcement = config('projectsend.platform.two_factor_enforcement');
if (is_string($enforcement) && $enforcement !== '') {
$this->seedTwoFactorEnforcement($settings, $enforcement);
}
return self::SUCCESS;
}
private function seedTwoFactorEnforcement(Settings $settings, string $value): void
{
if (TwoFactorEnforcement::tryFrom($value) === null) {
// Named rather than ignored. A typo here means a tenant
// provisioned without the policy it was meant to have, and
// silence would make that indistinguishable from success.
$this->warn("PROJECTSEND_TWO_FACTOR_ENFORCEMENT='{$value}' is not one of none, staff, clients, all — leaving the setting alone.");
return;
}
// Asked of the table rather than of Settings::get(), which cannot
// tell a stored value apart from the enum's own default — and
// 'none' is that default, so get() would report the thing we are
// trying to detect the absence of.
if (StoredSetting::query()->where('key', Setting::TwoFactorEnforcement->value)->exists()) {
return;
}
$settings->set(Setting::TwoFactorEnforcement, $value);
$this->info("Two-factor enforcement seeded to '{$value}' (first boot).");
}
}