feat: import work hours from SaaS and exchange signed time sheet

This commit is contained in:
fromme committed 2026-09-26 12:13:59 +02:00
1 parent 7cf39850e6
commit 1a7c272414
73 files changed
+10488 -72

No files matched your search

@@ -0,0 +1,99 @@
<?php
namespace App\Logging;
use Monolog\LogRecord;
/**
* Keeps SaaS api keys out of the log while leaving the requests themselves in it.
*
* The SaaS takes its api key as a path segment rather than in a header, so the url of every
* call carries a credential. Symfony's http client logs a line per request, and an HTTP error
* repeats the url inside the exception message, which then travels into whatever channel
* catches it - so silencing one client would not have been enough.
*
* Registered without a channel, deliberately: a url can surface anywhere, and a processor
* that only guarded the saas channel would miss the exception that matters most.
*
* Two passes, because the two failure modes are different. The configured key is redacted
* wherever it appears, whole or url-encoded, which is exact. The path shapes are redacted by
* pattern, which also covers the per-employee keys the roster hands out and any future
* endpoint that follows the same convention.
*/
class RedactSaasCredentialsProcessor
{
private const PLACEHOLDER = '[redacted]';
/**
* Anything sitting where an api key sits: /api/v2/<something>/<key>[/...].
*/
private const PATH_PATTERNS = [
'#(/api/v[0-9]+/time/account/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employees/)[^/\s"\']+#i',
'#(/api/v[0-9]+/employee/departments/)[^/\s"\']+#i',
];
/**
* @var string[]
*/
private readonly array $secrets;
public function __construct(string $apiKey)
{
$secrets = [];
// Short values would match far too much; a real key never is.
if (8 <= strlen($apiKey)) {
$secrets[$apiKey] = $apiKey;
$secrets[rawurlencode($apiKey)] = rawurlencode($apiKey);
}
$this->secrets = array_values($secrets);
}
public function __invoke(LogRecord $record): LogRecord
{
$message = $this->redact($record->message);
$context = $this->redactDeep($record->context);
$extra = $this->redactDeep($record->extra);
if ($message === $record->message && $context === $record->context && $extra === $record->extra) {
return $record;
}
// LogRecord only allows some of its fields to be assigned through array access;
// message is not one of them, so a redacted record is a new one.
return $record->with(message: $message, context: $context, extra: $extra);
}
private function redactDeep(array $values, int $depth = 0): array
{
// Contexts hold arbitrary payloads; stop rather than walk something pathological.
if (8 < $depth) {
return $values;
}
foreach ($values as $key => $value) {
if (is_string($value)) {
$values[$key] = $this->redact($value);
} elseif (is_array($value)) {
$values[$key] = $this->redactDeep($value, $depth + 1);
}
}
return $values;
}
private function redact(string $value): string
{
foreach ($this->secrets as $secret) {
$value = str_replace($secret, self::PLACEHOLDER, $value);
}
foreach (self::PATH_PATTERNS as $pattern) {
$value = (string) preg_replace($pattern, '$1'.self::PLACEHOLDER, $value);
}
return $value;
}
}