feat: import work hours from SaaS and exchange signed time sheet
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user