Files
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

98 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Settings\StoredSetting;
/**
* A policy that has to exist before the account it protects.
*
* On a managed installation the only writers of a setting are whoever
* administers it and this command — and the administrator is created in
* the same first boot, one line below. So enforcement seeded here covers
* the first seat; seeded by anything calling in afterwards does not.
*/
beforeEach(function () {
// Settings are cached across tests (Cache::rememberForever survives the
// per-test rollback), so the starting point is set rather than assumed.
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'none');
StoredSetting::query()->where('key', Setting::TwoFactorEnforcement->value)->delete();
app(Settings::class)->flush();
});
test('a setting nobody has stored is seeded from the environment', function () {
config(['projectsend.platform.two_factor_enforcement' => 'staff']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('staff');
});
test('a setting somebody has already chosen is left alone', function () {
// The whole design. An environment value that won every boot would
// take the setting away from the administrator it belongs to, and
// somebody who tightened it would find it loosened by a restart.
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
config(['projectsend.platform.two_factor_enforcement' => 'staff']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('all');
});
test('a stored value equal to the default still counts as chosen', function () {
// 'none' is the enum's default, so Settings::get() cannot tell it apart
// from nothing stored. An administrator who deliberately set 'none'
// must not have it overwritten on the next restart, which is why the
// command asks the table rather than the accessor.
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'none');
config(['projectsend.platform.two_factor_enforcement' => 'all']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('none');
});
test('an unset variable seeds nothing and says nothing', function () {
config(['projectsend.platform.two_factor_enforcement' => null]);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
expect(StoredSetting::query()->where('key', Setting::TwoFactorEnforcement->value)->exists())->toBeFalse();
});
test('a value that is not one of the four is named rather than ignored', function () {
// A typo here means a tenant provisioned without the policy it was
// meant to have. Silence would make that look like success.
config(['projectsend.platform.two_factor_enforcement' => 'stafff']);
$this->artisan('projectsend:seed-settings')
->expectsOutputToContain('is not one of none, staff, clients, all')
->assertSuccessful();
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('none');
});
test('the seeded policy is in force for the first account the same boot creates', function () {
// The point of the ordering, end to end: the entrypoint seeds and then
// creates the administrator, so that account is born under the policy
// rather than ahead of it.
config(['projectsend.platform.two_factor_enforcement' => 'staff']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
$this->artisan('projectsend:admin', [
'--name' => 'First',
'--email' => 'first@example.test',
'--password' => 'a-strong-password-1',
])->assertSuccessful();
$admin = User::query()->where('email', 'first@example.test')->sole();
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('staff')
->and($admin->hasTwoFactorEnabled())->toBeFalse();
});