Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/deploiement.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ RB_USER_PASSWORD='<mot-de-passe>' php bin/create-user.php <email> <nom> admin #

Le mot de passe, s'il y en a un, passe par l'environnement (jamais en argument). La connexion se fait avec l'**e-mail**. **Pas d'inscription publique** (#137) : `/register` et `POST /api/auth/register` n'existent plus. Les groupes sont créés depuis l'écran Groupes.

## Droits des fichiers du serveur (#440)

Chaque déploiement (étape « Droits des fichiers du serveur », avant la bascule) referme `shared/` (700), `backups/` (700), tout `shared/storage/` (dossiers 700, fichiers 600) et les fichiers de `shared/` (600) ; seul `shared/well-known` (défis du certificat) reste en 755. Il CONTRÔLE ensuite : un fichier de `shared/` encore accessible au groupe ou aux autres arrête le déploiement. Une copie `config.local.php.bak*` laissée par une manipulation manuelle est signalée (elle contient les secrets) : la supprimer une fois la vérification faite. Côté application : les journaux (dossier 700, fichier 600) et les documents (600) sont créés fermés par le code.

## Tâche planifiée : relances de la messagerie (#180, #178)

`bin/send-reminders.php` envoie, **en deux temps** (relances de groupe puis relances de mention, deux lignes de bilan, puis une ligne sur l'oubli des anciennes versions de messages corrigés, #225), à l'adresse de contact d'un groupe **une relance** quand un message de l'autre côté est resté **24 h sans lecture** par ce groupe (jamais le contenu du message), seulement entre 9 h et 20 h (heure locale `app.timezone`) ; hors plage il ne fait rien et les relances dues partent le matin. Un message de plus de 7 jours n'est plus relancé. La **relance de mention** (#178) part à l'adresse du compte d'une personne taguée qui n'a pas lu la conversation 24 h après l'e-mail de mention (une seule par e-mail de mention, jamais le contenu) ; la même plage de jour s'applique. Le script s'arrête en erreur (code 1) si l'un des deux envois échoue. Idempotent : peut être relancé sans doublon.
Expand Down
29 changes: 27 additions & 2 deletions bin/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ REMOTE_COMPOSER="${RB_REMOTE_COMPOSER:-/usr/local/bin/composer}"
KEEP_RELEASES=3

rb_ssh() { ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "$@"; }
# Étapes numérotées avec barre de progression (#277) : 12 étapes au total, celles qu'on saute comptent quand même.
# Étapes numérotées avec barre de progression (#277) : 13 étapes au total, celles qu'on saute comptent quand même.
source "$(dirname "$0")/lib/progress.sh"
source "$(dirname "$0")/lib/ci-status.sh"
# Journal des déploiements (#396) : une ligne par déploiement dans shared/deployments.jsonl, sans jamais bloquer le déploiement.
source "$(dirname "$0")/lib/deploy-log.sh"
progress_init 12
progress_init 13

secret() {
local value
Expand Down Expand Up @@ -78,6 +78,31 @@ rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-d
git archive --format=tar HEAD -- bin config database public src templates composer.json composer.lock ':(exclude)database/seed.php' \
| rb_ssh "tar -x -C \"\$HOME/$BASE/releases/$release\""

# --- 2b. Droits des fichiers du serveur (#440) ------------------------------
# Tout ce qui est sous shared/ (secrets, documents, journaux) et backups/ est réservé au propriétaire ; seul well-known (défis du
# certificat) reste lisible par le serveur web. Corrige l'existant à chaque déploiement, puis CONTRÔLE : un fichier de shared/ resté
# accessible au groupe ou aux autres arrête le déploiement avant la bascule.
progress_step "Droits des fichiers du serveur"
rb_ssh bash -s -- "$BASE" <<'REMOTE'
set -euo pipefail
cd "$HOME/$1"
chmod 700 shared backups
chmod -R go-rwx shared/storage backups
chmod 755 shared/well-known
find shared -maxdepth 1 -type f -exec chmod 600 {} +
leaks=$(find shared -path shared/well-known -prune -o -perm /077 -print)
if [ -n "$leaks" ]; then
echo "ÉCHEC : fichiers de shared/ accessibles au groupe ou aux autres :" >&2
echo "$leaks" >&2
exit 1
fi
backups=$(find shared -maxdepth 1 -name 'config.local.php.bak*' -print)
if [ -n "$backups" ]; then
echo "ATTENTION : copie de la configuration (contient les secrets) à supprimer une fois la vérification faite :" >&2
echo "$backups" >&2
fi
REMOTE

# --- 3. Dépendances et liens vers les éléments partagés -------------------
progress_step "composer install --no-dev (PHP CLI explicite)"
rb_ssh bash -s -- "$BASE" "$release" "$REMOTE_PHP" "$REMOTE_COMPOSER" <<'REMOTE'
Expand Down
2 changes: 2 additions & 0 deletions src/Group/Service/GroupDocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ public function upload(int $groupId, int $actorUserId, string $tmpFilePath, stri

throw $e;
}
// Lisible du seul propriétaire (#440) : le serveur web n'a pas à servir ce fichier, seul le téléchargement contrôlé le fait.
@chmod($destinationPath, 0600);

try {
// « Compter puis ajouter » est ATOMIQUE : le groupe est verrouillé le temps de la transaction, donc des envois
Expand Down
7 changes: 6 additions & 1 deletion src/Logging/FileLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,16 @@ public function log($level, string|\Stringable $message, array $context = []): v
private function write(string $line): void
{
$dir = dirname($this->file);
if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) {
// Dossier et fichier réservés au propriétaire (#440) : un journal peut citer des identifiants d'utilisateurs.
if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
return;
}
$this->rotateIfNeeded();
$created = !is_file($this->file);
@file_put_contents($this->file, $line, \FILE_APPEND | \LOCK_EX);
if ($created) {
@chmod($this->file, 0600);
}
}

private function rotateIfNeeded(): void
Expand Down
114 changes: 114 additions & 0 deletions tests/Bin/ServerPermissionsDeployTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace App\Tests\Bin;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/** #440 : chaque déploiement referme les droits de shared/ et de backups/, puis les contrôle avant la bascule. */
final class ServerPermissionsDeployTest extends TestCase
{
private string $home;

protected function setUp(): void
{
$this->home = sys_get_temp_dir() . '/rb-perms-' . bin2hex(random_bytes(4));
mkdir($this->home . '/app/shared/storage/group-documents', 0o775, true);
mkdir($this->home . '/app/shared/storage/logs', 0o775, true);
mkdir($this->home . '/app/shared/well-known', 0o775, true);
mkdir($this->home . '/app/backups', 0o755, true);
foreach (['shared/config.local.php' => 0o664, 'shared/reminders.log' => 0o644, 'shared/storage/logs/app.log' => 0o664, 'shared/storage/group-documents/doc.pdf' => 0o644, 'backups/db.sql.gz' => 0o644] as $file => $mode) {
file_put_contents($this->home . '/app/' . $file, 'x');
chmod($this->home . '/app/' . $file, $mode);
}
chmod($this->home . '/app/shared', 0o775);
}

protected function tearDown(): void
{
exec('rm -rf ' . escapeshellarg($this->home));
}

/** Le corps du bloc distant « Droits des fichiers du serveur » de bin/deploy.sh, tel qu'il s'exécute sur le serveur. */
private function remoteBlock(): string
{
$script = (string) file_get_contents(__DIR__ . '/../../bin/deploy.sh');
self::assertSame(1, preg_match("/progress_step \"Droits des fichiers du serveur\"\nrb_ssh bash -s -- \"\\\$BASE\" <<'REMOTE'\n(.*?)\nREMOTE\n/s", $script, $match), 'bloc distant introuvable');

return $match[1];
}

/** @return array{int, string} code de sortie et sortie d'erreur */
private function runBlock(): array
{
$process = proc_open(['bash', '-s', '--', 'app'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['HOME' => $this->home, 'PATH' => (string) getenv('PATH')]);
fwrite($pipes[0], $this->remoteBlock());
fclose($pipes[0]);
stream_get_contents($pipes[1]);
$errors = (string) stream_get_contents($pipes[2]);

return [proc_close($process), $errors];
}

private function mode(string $relative): string
{
return substr(sprintf('%o', fileperms($this->home . '/app/' . $relative)), -3);
}

#[Test]
public function testSecretsDocumentsLogsAndBackupsAreClosedToEveryoneButTheOwner(): void
{
[$code] = $this->runBlock();

self::assertSame(0, $code);
foreach (['shared' => '700', 'backups' => '700', 'shared/storage' => '700', 'shared/storage/group-documents' => '700', 'shared/config.local.php' => '600', 'shared/reminders.log' => '600', 'shared/storage/logs/app.log' => '600', 'shared/storage/group-documents/doc.pdf' => '600', 'backups/db.sql.gz' => '600'] as $path => $mode) {
self::assertSame($mode, $this->mode($path), $path);
}
}

#[Test]
public function testOnlyTheCertificateChallengeFolderStaysReadableByTheWebServer(): void
{
$this->runBlock();

self::assertSame('755', $this->mode('shared/well-known'));
}

#[Test]
public function testLeftoverConfigurationCopiesAreReportedNotDeleted(): void
{
file_put_contents($this->home . '/app/shared/config.local.php.bak-20261011', 'secrets');

[$code, $errors] = $this->runBlock();

self::assertSame(0, $code, 'un avertissement, pas un échec');
self::assertStringContainsString('config.local.php.bak-20261011', $errors);
self::assertFileExists($this->home . '/app/shared/config.local.php.bak-20261011');
}

#[Test]
public function testTheCheckStopsTheDeploymentWhenSomethingStaysOpen(): void
{
// Un fichier que le propriétaire ne peut pas refermer : lien vers un fichier d'un autre compte ou système de fichiers sans droits.
$script = str_replace('find shared -maxdepth 1 -type f -exec chmod 600 {} +', 'true', $this->remoteBlock());
$process = proc_open(['bash', '-s', '--', 'app'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['HOME' => $this->home, 'PATH' => (string) getenv('PATH')]);
fwrite($pipes[0], $script);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
$errors = (string) stream_get_contents($pipes[2]);

self::assertSame(1, proc_close($process));
self::assertStringContainsString('ÉCHEC', $errors);
self::assertStringContainsString('config.local.php', $errors);
}

#[Test]
public function testTheStepRunsBeforeTheReleaseIsSwitched(): void
{
$script = (string) file_get_contents(__DIR__ . '/../../bin/deploy.sh');

self::assertLessThan(strpos($script, 'ln -sfn "$HOME/$base/releases/$rel" current.new'), strpos($script, 'Droits des fichiers du serveur'));
}
}
13 changes: 13 additions & 0 deletions tests/Group/Service/GroupDocumentServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -534,4 +534,17 @@ public function testTheTotalVolumeOfAGroupIsCappedAndARefusedFileLeavesNothingOn

self::assertCount(1, $this->storedFiles());
}

#[Test]
public function testAStoredDocumentIsReadableByTheOwnerOnly(): void
{
[$service, $groupRepository, $userRepository] = $this->makeService();
$group = $groupRepository->save(new Group(0, 'Groupe Droits', null, null, 'contact@example.test'));
$manager = $this->createUser($userRepository, 'droits@rehearsalbox.test');
$groupRepository->addMember($group->id(), $manager->id(), GroupUserRole::Gestionnaire);

$document = $service->upload($group->id(), $manager->id(), $this->fakeUploadedFile('%PDF-1.4 x', 'a.pdf'), 'a.pdf', 10);

self::assertSame('0600', substr(sprintf('%o', fileperms($service->pathOf($document))), -4));
}
}
14 changes: 13 additions & 1 deletion tests/Logging/FileLoggerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ public function testTheDirectoryIsCreatedOnFirstWriteWithPrivatePermissions(): v
{
$this->logger()->error('Premier message');

self::assertSame('0750', substr(sprintf('%o', fileperms($this->dir)), -4));
self::assertSame('0700', substr(sprintf('%o', fileperms($this->dir)), -4));
}

#[Test]
public function testTheLogFileAndItsDirectoryAreReadableByTheOwnerOnly(): void
{
$nested = $this->dir . '/neuf';
(new FileLogger($nested . '/app.log', new MockClock('2026-10-07 10:00:00 UTC')))->error('Une ligne');

self::assertSame('0600', substr(sprintf('%o', fileperms($nested . '/app.log')), -4), 'des identifiants d\'utilisateurs peuvent figurer dans un journal');
self::assertSame('0700', substr(sprintf('%o', fileperms($nested)), -4));
unlink($nested . '/app.log');
rmdir($nested);
}
}
Loading