mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
ac691387e8
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.
90 lines
3.6 KiB
PHP
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).");
|
|
}
|
|
}
|