First version of the security module

This commit is contained in:
2026-06-22 13:19:05 +02:00
parent 0392043ee3
commit 498afc7840
64 changed files with 7581 additions and 4 deletions

View File

@@ -0,0 +1,223 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
/**
* Business logic for security check templates (the per-product checklist catalog).
*
* A template owns the dynamic technical checklist for one software product. The
* checklist schema shape is:
* { "version": int, "items": [ {type:"section", label}, {type:"check", key, label, hint?, markdown?} ] }
* Item keys are auto-slugified from labels and kept unique within a template.
*
* @api Consumed by Security template pages and the create wizard.
*/
class SecurityCheckTemplateService
{
public const MAX_ITEMS = 100;
private const ALLOWED_ITEM_TYPES = ['section', 'check'];
public function __construct(
private readonly SecurityCheckTemplateRepository $repository
) {
}
/**
* @api Called from the create wizard product picker
* @return list<array<string, mixed>>
*/
public function listActive(int $tenantId): array
{
return $this->repository->listActive($tenantId);
}
/**
* @api Called from templates-data endpoint
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
public function findById(int $tenantId, int $id): ?array
{
return $this->repository->findById($tenantId, $id);
}
public function findByCode(int $tenantId, string $code): ?array
{
return $this->repository->findByCode($tenantId, $code);
}
/**
* Decode a template's stored tech schema into {version, items}.
*
* @param array<string, mixed> $template
* @return array{version: int, items: list<array<string, mixed>>}
*/
public function decodeSchema(array $template): array
{
$decoded = json_decode((string) ($template['tech_schema_json'] ?? ''), true);
if (!is_array($decoded)) {
return ['version' => (int) ($template['version'] ?? 1), 'items' => []];
}
$items = is_array($decoded['items'] ?? null) ? array_values($decoded['items']) : [];
return ['version' => (int) ($decoded['version'] ?? ($template['version'] ?? 1)), 'items' => $items];
}
/**
* @return array{ok: bool, id?: int, errors: array<string, string>}
*/
public function create(int $tenantId, string $code, string $name, int $actorUserId): array
{
$code = trim($code);
$name = trim($name);
$errors = [];
if ($tenantId <= 0) {
$errors['tenant'] = t('No tenant context');
}
if ($code === '') {
$errors['product_code'] = t('Product code cannot be empty');
} elseif (!preg_match('/^[A-Za-z0-9._-]+$/', $code)) {
$errors['product_code'] = t('Product code is invalid');
} elseif ($this->repository->codeExists($tenantId, $code)) {
$errors['product_code'] = t('A template with this product code already exists');
}
if ($name === '') {
$errors['product_name'] = t('Product name cannot be empty');
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$id = $this->repository->insert($tenantId, [
'product_code' => $code,
'product_name' => $name,
'tech_schema_json' => json_encode(['version' => 1, 'items' => []]),
'active' => 1,
'version' => 1,
'created_by' => $actorUserId,
]);
if ($id === null) {
return ['ok' => false, 'errors' => ['general' => t('Template could not be created')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* @return array{ok: bool, errors: array<string, string>}
*/
public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $actorUserId): array
{
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'errors' => ['product_name' => t('Product name cannot be empty')]];
}
if ($this->repository->findById($tenantId, $id) === null) {
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
}
$ok = $this->repository->updateMeta($tenantId, $id, $name, $active, $actorUserId);
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Template could not be updated')]];
}
/**
* Validate + normalize the technical checklist and persist it (version bump).
*
* @param array<int, mixed> $items
* @return array{ok: bool, errors: array<string, string>, version?: int}
*/
public function saveTechSchema(int $tenantId, int $id, array $items, int $actorUserId): array
{
$template = $this->repository->findById($tenantId, $id);
if ($template === null) {
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
}
if (count($items) > self::MAX_ITEMS) {
return ['ok' => false, 'errors' => ['schema' => t('Maximum %d items allowed', self::MAX_ITEMS)]];
}
$normalized = [];
$usedKeys = [];
foreach ($items as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? 'check');
if (!in_array($type, self::ALLOWED_ITEM_TYPES, true)) {
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid type', $index + 1)]];
}
$label = trim((string) ($item['label'] ?? ''));
if ($label === '') {
return ['ok' => false, 'errors' => ['schema' => t('Item %d is missing a label', $index + 1)]];
}
if ($type === 'section') {
$normalized[] = ['type' => 'section', 'label' => $label];
continue;
}
$key = trim((string) ($item['key'] ?? ''));
if ($key === '') {
$key = self::slugify($label);
}
$key = self::slugify($key);
if ($key === '') {
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid key', $index + 1)]];
}
if (isset($usedKeys[$key])) {
$suffix = 2;
while (isset($usedKeys[$key . '_' . $suffix])) {
$suffix++;
}
$key .= '_' . $suffix;
}
$usedKeys[$key] = true;
$entry = ['type' => 'check', 'key' => $key, 'label' => $label];
$hint = trim((string) ($item['hint'] ?? ''));
if ($hint !== '') {
$entry['hint'] = $hint;
}
$markdown = trim((string) ($item['markdown'] ?? ''));
if ($markdown !== '') {
$entry['markdown'] = $markdown;
}
$normalized[] = $entry;
}
$currentVersion = (int) ($template['version'] ?? 1);
$newVersion = $currentVersion + 1;
$schemaJson = json_encode(['version' => $newVersion, 'items' => $normalized], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($schemaJson)) {
return ['ok' => false, 'errors' => ['schema' => t('Could not encode the checklist')]];
}
$ok = $this->repository->updateSchema($tenantId, $id, $schemaJson, $newVersion, $actorUserId);
return $ok
? ['ok' => true, 'errors' => [], 'version' => $newVersion]
: ['ok' => false, 'errors' => ['general' => t('Checklist could not be saved')]];
}
private static function slugify(string $text): string
{
$text = trim(mb_strtolower($text));
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$text = preg_replace('/[^a-z0-9]+/', '_', $text) ?? '';
return trim($text, '_');
}
}