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,43 @@
<?php
namespace MintyPHP\Module\Security\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationService;
/**
* Resolves the can_* navigation flags for the Security sidebar panel.
*
* Merged into $layoutNav under the 'security.nav' key and consumed by
* templates/aside-security-panel.phtml.
*/
final class SecurityLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
$userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
return ['security.nav' => []];
}
try {
$authorizationService = $container->get(AuthorizationService::class);
$actorContext = ['actor_user_id' => $userId];
$canViewChecks = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW, $actorContext)->isAllowed();
$canManageTemplates = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE, $actorContext)->isAllowed();
$canManageSettings = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE, $actorContext)->isAllowed();
} catch (\Throwable) {
$canViewChecks = false;
$canManageTemplates = false;
$canManageSettings = false;
}
return ['security.nav' => [
'can_view_checks' => $canViewChecks,
'can_manage_templates' => $canManageTemplates,
'can_manage_settings' => $canManageSettings,
]];
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
/**
* SQL access for security_checks. Tenant-scoped, prepared statements only.
*/
class SecurityCheckRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(int $tenantId, array $data): ?int
{
$result = DB::insert(
'INSERT INTO security_checks'
. ' (tenant_id, debitor_no, debitor_name, domain_no, domain_url, product_code, product_name,'
. ' template_id, owner_user_id, status, process_state_json, tech_schema_snapshot_json, tech_state_json, created_by)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(string) $tenantId,
(string) ($data['debitor_no'] ?? ''),
(string) ($data['debitor_name'] ?? ''),
(string) ($data['domain_no'] ?? ''),
(string) ($data['domain_url'] ?? ''),
(string) ($data['product_code'] ?? ''),
(string) ($data['product_name'] ?? ''),
isset($data['template_id']) ? (string) ((int) $data['template_id']) : null,
isset($data['owner_user_id']) ? (string) ((int) $data['owner_user_id']) : null,
(string) ($data['status'] ?? 'open'),
$data['process_state_json'] ?? null,
$data['tech_schema_snapshot_json'] ?? null,
$data['tech_state_json'] ?? null,
(string) ((int) ($data['created_by'] ?? 0))
);
return $result === false ? null : (int) $result;
}
public function findById(int $tenantId, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'SELECT c.*, uo.display_name AS owner_name, uc.display_name AS created_by_name, uu.display_name AS updated_by_name'
. ' FROM security_checks c'
. ' LEFT JOIN users uo ON uo.id = c.owner_user_id'
. ' LEFT JOIN users uc ON uc.id = c.created_by'
. ' LEFT JOIN users uu ON uu.id = c.updated_by'
. ' WHERE c.id = ? AND c.tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
/**
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = trim((string) ($filters['status'] ?? ''));
$view = trim((string) ($filters['view'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'debitor_name', 'domain_url', 'product_name', 'status', 'created_at', 'updated_at'],
'created_at',
'desc'
);
$where = ['c.tenant_id = ?'];
$params = [(string) $tenantId];
RepoQuery::addLikeFilter($where, $params, ['c.debitor_name', 'c.debitor_no', 'c.domain_url', 'c.product_name'], $search);
if ($status !== '' && $status !== 'all') {
$where[] = 'c.status = ?';
$params[] = $status;
} elseif ($view === 'active') {
$where[] = "c.status IN ('open', 'in_progress')";
} elseif ($view === 'done') {
$where[] = "c.status IN ('completed', 'archived')";
}
$whereSql = ' WHERE ' . implode(' AND ', $where);
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM security_checks c' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'SELECT c.*, uo.display_name AS owner_name, uc.display_name AS created_by_name'
. ' FROM security_checks c'
. ' LEFT JOIN users uo ON uo.id = c.owner_user_id'
. ' LEFT JOIN users uc ON uc.id = c.created_by'
. $whereSql
. sprintf(' ORDER BY c.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
/**
* Active users in the tenant, for owner assignment. Ordered by display name.
*
* @return list<array{id: int, display_name: string}>
*/
public function listTenantUsers(int $tenantId): array
{
$rows = DB::select(
'SELECT u.id, u.display_name FROM users u'
. ' JOIN user_tenants ut ON ut.user_id = u.id'
. ' WHERE ut.tenant_id = ? AND u.active = 1'
. ' ORDER BY u.display_name ASC',
(string) $tenantId
);
$users = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$flat = $this->normalizeRow($row);
if ($flat === null) {
continue;
}
$id = (int) ($flat['id'] ?? 0);
if ($id > 0) {
$users[] = ['id' => $id, 'display_name' => (string) ($flat['display_name'] ?? '')];
}
}
}
return $users;
}
/**
* @param array<string, mixed> $data
*/
public function updateState(int $tenantId, int $id, array $data): bool
{
$result = DB::update(
'UPDATE security_checks SET process_state_json = ?, tech_state_json = ?, status = ?, updated_by = ?'
. ' WHERE id = ? AND tenant_id = ?',
$data['process_state_json'] ?? null,
$data['tech_state_json'] ?? null,
(string) ($data['status'] ?? 'open'),
(string) ((int) ($data['updated_by'] ?? 0)),
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function updateStatus(int $tenantId, int $id, string $status, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_checks SET status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$status,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function deleteById(int $tenantId, int $id): bool
{
if ($id <= 0) {
return false;
}
return (bool) DB::delete(
'DELETE FROM security_checks WHERE id = ? AND tenant_id = ?',
(string) $id,
(string) $tenantId
);
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
return isset($item['id']) ? $item : null;
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
/**
* SQL access for security_check_templates. Tenant-scoped, prepared statements only.
*/
class SecurityCheckTemplateRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(int $tenantId, array $data): ?int
{
$result = DB::insert(
'INSERT INTO security_check_templates'
. ' (tenant_id, product_code, product_name, tech_schema_json, active, version, created_by, updated_by)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(string) $tenantId,
(string) ($data['product_code'] ?? ''),
(string) ($data['product_name'] ?? ''),
$data['tech_schema_json'] ?? null,
(string) ((int) ($data['active'] ?? 1)),
(string) ((int) ($data['version'] ?? 1)),
(string) ((int) ($data['created_by'] ?? 0)),
(string) ((int) ($data['created_by'] ?? 0))
);
return $result === false ? null : (int) $result;
}
public function findById(int $tenantId, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'SELECT t.*, uc.display_name AS created_by_name, uu.display_name AS updated_by_name'
. ' FROM security_check_templates t'
. ' LEFT JOIN users uc ON uc.id = t.created_by'
. ' LEFT JOIN users uu ON uu.id = t.updated_by'
. ' WHERE t.id = ? AND t.tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
public function findByCode(int $tenantId, string $code): ?array
{
$code = trim($code);
if ($code === '') {
return null;
}
$row = DB::selectOne(
'SELECT * FROM security_check_templates WHERE tenant_id = ? AND product_code = ? LIMIT 1',
(string) $tenantId,
$code
);
return $this->normalizeRow($row);
}
public function codeExists(int $tenantId, string $code, int $exceptId = 0): bool
{
$code = trim($code);
if ($code === '') {
return false;
}
$count = (int) (DB::selectValue(
'SELECT COUNT(*) FROM security_check_templates WHERE tenant_id = ? AND product_code = ? AND id <> ?',
(string) $tenantId,
$code,
(string) $exceptId
) ?? 0);
return $count > 0;
}
/**
* Active templates for the wizard product picker.
*
* @return list<array<string, mixed>>
*/
public function listActive(int $tenantId): array
{
$rows = DB::select(
'SELECT id, product_code, product_name, tech_schema_json FROM security_check_templates'
. ' WHERE tenant_id = ? AND active = 1 ORDER BY product_name ASC',
(string) $tenantId
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
/**
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$active = trim((string) ($filters['active'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'product_code', 'product_name', 'active', 'version', 'updated_at', 'created_at'],
'product_name',
'asc'
);
$where = ['t.tenant_id = ?'];
$params = [(string) $tenantId];
RepoQuery::addLikeFilter($where, $params, ['t.product_code', 't.product_name'], $search);
if ($active === '1' || $active === '0') {
$where[] = 't.active = ?';
$params[] = $active;
}
$whereSql = ' WHERE ' . implode(' AND ', $where);
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM security_check_templates t' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'SELECT t.*, uu.display_name AS updated_by_name'
. ' FROM security_check_templates t'
. ' LEFT JOIN users uu ON uu.id = t.updated_by'
. $whereSql
. sprintf(' ORDER BY t.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_check_templates SET product_name = ?, active = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$name,
(string) ($active ? 1 : 0),
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function updateSchema(int $tenantId, int $id, string $schemaJson, int $version, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_check_templates SET tech_schema_json = ?, version = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$schemaJson,
(string) $version,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
// JOIN queries return nested arrays keyed by table name plus scalar aliased
// columns — merge sub-arrays and copy scalars (same convention as core repos).
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
return isset($item['id']) ? $item : null;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
/**
* OAuth2 token cache for the Security BC gateway (security_oauth_token_cache).
*
* Tokens are stored encrypted and tenant-scoped.
*/
class SecurityTokenRepository
{
public function __construct(
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
) {
}
public function findValidToken(int $tenantId): ?string
{
if ($tenantId <= 0) {
return null;
}
$result = DB::selectOne(
'SELECT access_token_encrypted FROM security_oauth_token_cache'
. ' WHERE tenant_id = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
(string) $tenantId
);
$row = is_array($result) ? ($result['security_oauth_token_cache'] ?? $result) : null;
if (!is_array($row) || !isset($row['access_token_encrypted'])) {
return null;
}
$encrypted = trim((string) $row['access_token_encrypted']);
if ($encrypted === '') {
return null;
}
try {
return $this->settingsCryptoGateway->decryptString($encrypted);
} catch (\Throwable) {
return null;
}
}
public function storeToken(int $tenantId, string $accessToken, \DateTimeInterface $expiresAt): bool
{
if ($tenantId <= 0 || $accessToken === '') {
return false;
}
try {
$encrypted = $this->settingsCryptoGateway->encryptString($accessToken);
} catch (\Throwable) {
return false;
}
DB::delete('DELETE FROM security_oauth_token_cache WHERE tenant_id = ?', (string) $tenantId);
return (bool) DB::insert(
'INSERT INTO security_oauth_token_cache (tenant_id, access_token_encrypted, expires_at) VALUES (?, ?, ?)',
(string) $tenantId,
$encrypted,
$expiresAt->format('Y-m-d H:i:s')
);
}
/**
* Clear all cached tokens — used when the global BC connection changes.
*
* @api Called from the Security settings page on connection change.
*/
public function deleteAll(): bool
{
return (bool) DB::delete('DELETE FROM security_oauth_token_cache WHERE 1=1');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Module\Security;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
/**
* Authorization policy for the Security module.
*
* Maps module abilities to permission keys resolved via the core RBAC
* PermissionService. Registered by the core AuthorizationService through the
* module manifest's authorization_policies.
*
* @api Ability/permission constants are referenced from Security page actions.
*/
final class SecurityAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ACCESS = 'security.access';
public const ABILITY_CHECKS_VIEW = 'security.checks.view';
public const ABILITY_CHECKS_CREATE = 'security.checks.create';
public const ABILITY_CHECKS_MANAGE = 'security.checks.manage';
public const ABILITY_TEMPLATES_MANAGE = 'security.templates.manage';
public const ABILITY_SETTINGS_MANAGE = 'security.settings.manage';
public const PERMISSION_ACCESS = 'security.access';
public const PERMISSION_CHECKS_VIEW = 'security.checks.view';
public const PERMISSION_CHECKS_CREATE = 'security.checks.create';
public const PERMISSION_CHECKS_MANAGE = 'security.checks.manage';
public const PERMISSION_TEMPLATES_MANAGE = 'security.templates.manage';
public const PERMISSION_SETTINGS_MANAGE = 'security.settings.manage';
public function __construct(
private readonly PermissionService $permissionService
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ACCESS,
self::ABILITY_CHECKS_VIEW,
self::ABILITY_CHECKS_CREATE,
self::ABILITY_CHECKS_MANAGE,
self::ABILITY_TEMPLATES_MANAGE,
self::ABILITY_SETTINGS_MANAGE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
$permissionKey = match ($ability) {
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
self::ABILITY_CHECKS_VIEW => self::PERMISSION_CHECKS_VIEW,
self::ABILITY_CHECKS_CREATE => self::PERMISSION_CHECKS_CREATE,
self::ABILITY_CHECKS_MANAGE => self::PERMISSION_CHECKS_MANAGE,
self::ABILITY_TEMPLATES_MANAGE => self::PERMISSION_TEMPLATES_MANAGE,
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
default => null,
};
if ($permissionKey === null) {
return AuthorizationDecision::deny(403, 'forbidden');
}
if (!$this->permissionService->userHas($actorUserId, $permissionKey)) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace MintyPHP\Module\Security;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Tenant\TenantLogoService;
final class SecurityContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->set(SecurityAuthorizationPolicy::class, static fn (AppContainer $c): SecurityAuthorizationPolicy => new SecurityAuthorizationPolicy(
$c->get(PermissionService::class)
));
// --- Thin BC layer ---
$container->set(SecurityBcSettingsGateway::class, static fn (AppContainer $c): SecurityBcSettingsGateway => new SecurityBcSettingsGateway(
$c->get(SettingsMetadataGateway::class),
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(SecurityTokenRepository::class, static fn (AppContainer $c): SecurityTokenRepository => new SecurityTokenRepository(
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(SecurityOAuthTokenService::class, static fn (AppContainer $c): SecurityOAuthTokenService => new SecurityOAuthTokenService(
$c->get(SecurityBcSettingsGateway::class),
$c->get(SecurityTokenRepository::class)
));
$container->set(SecurityBcGateway::class, static fn (AppContainer $c): SecurityBcGateway => new SecurityBcGateway(
$c->get(SecurityBcSettingsGateway::class),
$c->get(SecurityOAuthTokenService::class),
$c->get(SessionStoreInterface::class)
));
// --- Check templates (per-product checklist catalog) ---
$container->set(SecurityCheckTemplateRepository::class, static fn (): SecurityCheckTemplateRepository => new SecurityCheckTemplateRepository());
$container->set(SecurityCheckTemplateService::class, static fn (AppContainer $c): SecurityCheckTemplateService => new SecurityCheckTemplateService(
$c->get(SecurityCheckTemplateRepository::class)
));
$container->set(SecurityMarkdownGateway::class, static fn (): SecurityMarkdownGateway => new SecurityMarkdownGateway());
// --- Security checks (instances) ---
$container->set(SecurityCheckProcess::class, static fn (): SecurityCheckProcess => new SecurityCheckProcess());
$container->set(SecurityCheckRepository::class, static fn (): SecurityCheckRepository => new SecurityCheckRepository());
$container->set(SecurityCheckService::class, static fn (AppContainer $c): SecurityCheckService => new SecurityCheckService(
$c->get(SecurityCheckRepository::class),
$c->get(SecurityCheckTemplateService::class),
$c->get(SecurityCheckProcess::class)
));
$container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService(
$c->get(SecurityCheckProcess::class),
$c->get(BrandingLogoService::class),
$c->get(TenantLogoService::class)
));
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Http\SessionStoreInterface;
/**
* Thin Business Central OData V4 gateway for the Security module.
*
* Standalone and intentionally minimal: only the two queries the security-check
* wizard needs (customer search + domains-for-customer) plus a connection test.
* Supports Basic and OAuth2 auth modes. No remote assets, TLS verification on
* (GR-SEC-004).
*
* @api Consumed by the customer-search / customer-domains / settings-test pages.
*/
class SecurityBcGateway
{
public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly SecurityBcSettingsGateway $settingsGateway,
private readonly SecurityOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* Search customers by name or number.
*
* Tries contains() first, falls back to startswith() / range filter for BC
* versions that reject contains().
*
* @return array<int, array<string, mixed>>
*/
public function searchCustomers(string $query): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$escaped = $this->escapeODataStrictUserInput($query);
$upperQuery = mb_strtoupper($escaped);
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail';
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
$strategies = [
"contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
"startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
"Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
];
$lastException = null;
foreach ($strategies as $filter) {
$url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
try {
$response = $this->request('GET', $url);
if ($response !== null) {
return $this->extractODataValues($response);
}
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
} catch (\RuntimeException $e) {
$lastException = $e;
continue;
}
}
throw $lastException ?? new \RuntimeException('All OData search strategies failed');
}
/**
* Get active domains for a customer.
*
* @return array<int, array<string, mixed>>
*/
public function listDomainsForCustomer(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return [];
}
$escaped = $this->escapeODataStrictUserInput($customerNo);
$filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'";
$select = 'No,Customer_No,URL,State';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
. '?$filter=' . rawurlencode($filter)
. '&$top=200'
. '&$select=' . rawurlencode($select)
. '&$orderby=' . rawurlencode('URL asc');
$response = $this->request('GET', $url);
if ($response === null) {
return [];
}
return $this->extractODataValues($response);
}
/**
* Test the configured BC connection.
*
* @return array{ok: bool, error?: string, url?: string, http_code?: int}
*/
public function testConnection(): array
{
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'Configuration incomplete'];
}
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$top=1&$select=No';
$ch = curl_init();
if ($ch === false) {
return ['ok' => false, 'error' => 'curl_init failed'];
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$this->applyAuth($ch);
$responseBody = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($curlErrno !== 0) {
return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
}
if ($httpCode < 200 || $httpCode >= 300) {
$hint = match ($httpCode) {
401 => 'Unauthorized — check credentials',
403 => 'Forbidden — user has no access to this endpoint',
404 => 'Not found — check OData URL and company name',
default => 'HTTP ' . $httpCode,
};
return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
}
if (!is_string($responseBody)) {
return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
}
return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
}
/**
* @return array<string, mixed>|null
*/
private function request(string $method, string $url): ?array
{
if (!$this->settingsGateway->isConfigured()) {
return null;
}
$ch = curl_init();
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$this->applyAuth($ch);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
if ($httpCode >= 400 && $httpCode < 500) {
throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
}
return null;
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
/**
* @param \CurlHandle $ch
*/
private function applyAuth($ch): void
{
$authMode = $this->settingsGateway->getAuthMode();
if ($authMode === SecurityBcSettingsGateway::AUTH_MODE_BASIC) {
$user = $this->settingsGateway->getBasicUser() ?? '';
$password = $this->settingsGateway->getBasicPassword() ?? '';
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
return;
}
if ($authMode !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
return;
}
$tenantId = $this->resolveCurrentTenantId();
if ($tenantId <= 0) {
return;
}
$token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience());
if ($token === null || trim($token) === '') {
return;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'OData-Version: 4.0',
'Authorization: Bearer ' . $token,
]);
}
/**
* @param array<string, mixed> $response
* @return array<int, array<string, mixed>>
*/
private function extractODataValues(array $response): array
{
$values = $response['value'] ?? [];
if (!is_array($values)) {
return [];
}
return array_values($values);
}
/**
* @throws \InvalidArgumentException If the value contains disallowed characters.
*/
private function escapeODataStrictUserInput(string $value): string
{
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
throw new \InvalidArgumentException('Search query contains invalid characters.');
}
return str_replace("'", "''", $value);
}
private function resolveCurrentTenantId(): int
{
try {
$session = $this->sessionStore->all();
} catch (\Throwable) {
return 0;
}
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
return $tenantId > 0 ? $tenantId : 0;
}
private function buildOAuthAudience(): ?string
{
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
if ($baseUrl === '') {
return null;
}
$parsed = parse_url($baseUrl);
if (!is_array($parsed) || empty($parsed['host'])) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
$host = (string) $parsed['host'];
$port = isset($parsed['port']) ? ':' . (int) $parsed['port'] : '';
return $scheme . '://' . $host . $port;
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
/**
* Global Business Central connection settings for the Security module.
*
* Standalone, intentionally thin: a single global connection stored in the core
* `settings` table (GR-CORE-011). Secrets are encrypted via SettingsCryptoGateway
* (GR-SEC-005). Setting keys are prefixed with 'security.' to avoid collisions
* with the helpdesk module's own BC settings.
*
* Per-tenant connection overrides are deliberately out of scope for v1.
*
* @api Consumed by the Security settings page and SecurityBcGateway.
*/
class SecurityBcSettingsGateway
{
public const KEY_AUTH_MODE = 'security.bc_auth_mode';
public const KEY_ODATA_BASE_URL = 'security.bc_odata_base_url';
public const KEY_COMPANY_NAME = 'security.bc_company_name';
public const KEY_BASIC_USER = 'security.bc_basic_user';
public const KEY_BASIC_PASSWORD_ENC = 'security.bc_basic_password_enc';
public const KEY_OAUTH_TENANT_ID = 'security.bc_oauth_tenant_id';
public const KEY_OAUTH_CLIENT_ID = 'security.bc_oauth_client_id';
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'security.bc_oauth_client_secret_enc';
public const KEY_OAUTH_TOKEN_ENDPOINT = 'security.bc_oauth_token_endpoint';
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
public function __construct(
private readonly SettingsMetadataGateway $settingsMetadataGateway,
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
) {
}
public function getAuthMode(): string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_AUTH_MODE) ?? ''));
return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC;
}
public function setAuthMode(string $mode): bool
{
$mode = trim($mode);
if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) {
return false;
}
return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, self::KEY_AUTH_MODE);
}
public function getODataBaseUrl(): string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL) ?? ''));
return $value !== '' ? rtrim($value, '/') : '';
}
public function setODataBaseUrl(?string $url): bool
{
$url = trim((string) ($url ?? ''));
if ($url !== '' && !preg_match('#^https://#i', $url)) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_ODATA_BASE_URL,
$url !== '' ? rtrim($url, '/') : null,
self::KEY_ODATA_BASE_URL
);
}
public function getCompanyName(): string
{
return trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME) ?? ''));
}
public function setCompanyName(?string $name): bool
{
$name = trim((string) ($name ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_COMPANY_NAME,
$name !== '' ? $name : null,
self::KEY_COMPANY_NAME
);
}
public function getBasicUser(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER) ?? ''));
return $value !== '' ? $value : null;
}
public function setBasicUser(?string $user): bool
{
$user = trim((string) ($user ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_BASIC_USER,
$user !== '' ? $user : null,
self::KEY_BASIC_USER
);
}
public function getBasicPassword(): ?string
{
return $this->decryptSetting(self::KEY_BASIC_PASSWORD_ENC);
}
public function setBasicPassword(?string $password): bool
{
return $this->encryptSetting(self::KEY_BASIC_PASSWORD_ENC, $password);
}
public function getOAuthTenantId(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthTenantId(?string $tenantId): bool
{
$tenantId = trim((string) ($tenantId ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_TENANT_ID,
$tenantId !== '' ? $tenantId : null,
self::KEY_OAUTH_TENANT_ID
);
}
public function getOAuthClientId(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthClientId(?string $clientId): bool
{
$clientId = trim((string) ($clientId ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_CLIENT_ID,
$clientId !== '' ? $clientId : null,
self::KEY_OAUTH_CLIENT_ID
);
}
public function getOAuthClientSecret(): ?string
{
return $this->decryptSetting(self::KEY_OAUTH_CLIENT_SECRET_ENC);
}
public function setOAuthClientSecret(?string $secret): bool
{
return $this->encryptSetting(self::KEY_OAUTH_CLIENT_SECRET_ENC, $secret);
}
public function getOAuthTokenEndpoint(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthTokenEndpoint(?string $endpoint): bool
{
$endpoint = trim((string) ($endpoint ?? ''));
if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_TOKEN_ENDPOINT,
$endpoint !== '' ? $endpoint : null,
self::KEY_OAUTH_TOKEN_ENDPOINT
);
}
/**
* Build the full OData entity URL for a given entity set.
*/
public function buildEntityUrl(string $entitySet): string
{
$baseUrl = $this->getODataBaseUrl();
$company = rawurlencode($this->getCompanyName());
return $baseUrl . "/Company('" . $company . "')/" . $entitySet;
}
/**
* Validate that required settings for the current auth mode are present.
*
* @return array<int, string> List of missing setting descriptions (empty = valid)
*/
public function validateConfiguration(): array
{
$missing = [];
if ($this->getODataBaseUrl() === '') {
$missing[] = 'BC OData Base URL';
}
if ($this->getCompanyName() === '') {
$missing[] = 'BC Company Name';
}
if ($this->getAuthMode() === self::AUTH_MODE_BASIC) {
if ($this->getBasicUser() === null) {
$missing[] = 'BC Basic Auth User';
}
if ($this->getBasicPassword() === null) {
$missing[] = 'BC Basic Auth Password';
}
} else {
if ($this->getOAuthClientId() === null) {
$missing[] = 'BC OAuth2 Client ID';
}
if ($this->getOAuthClientSecret() === null) {
$missing[] = 'BC OAuth2 Client Secret';
}
if ($this->getOAuthTokenEndpoint() === null) {
$missing[] = 'BC OAuth2 Token Endpoint';
}
}
return $missing;
}
public function isConfigured(): bool
{
return $this->validateConfiguration() === [];
}
private function decryptSetting(string $key): ?string
{
$encrypted = trim((string) ($this->settingsMetadataGateway->getValue($key) ?? ''));
if ($encrypted === '') {
return null;
}
try {
$decrypted = trim($this->settingsCryptoGateway->decryptString($encrypted));
} catch (\Throwable) {
return null;
}
return $decrypted !== '' ? $decrypted : null;
}
private function encryptSetting(string $key, ?string $value): bool
{
$value = trim((string) ($value ?? ''));
if ($value === '') {
return $this->settingsMetadataGateway->set($key, null, $key);
}
try {
$encrypted = $this->settingsCryptoGateway->encryptString($value);
} catch (\Throwable) {
return false;
}
return $this->settingsMetadataGateway->set($key, $encrypted, $key);
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace MintyPHP\Module\Security\Service;
/**
* The fixed internal security-check process.
*
* Identical for every check, so it lives in code (not the DB). Step labels and
* descriptions are translation keys — call t() at render time. The tech-checklist
* step (perform_check) expands into the per-product items from the check's
* tech_schema_snapshot.
*
* Pure logic only (no DB, no HTTP) — fully unit-testable.
*
* @api Consumed by Security pages, views and SecurityCheckService.
*/
final class SecurityCheckProcess
{
public const STATUS_OPEN = 'open';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STEP_INFORMATION = 'information_gathering';
public const STEP_PERFORM_CHECK = 'perform_check';
public const STEP_REPORT = 'report';
public const KIND_STEP = 'step';
public const KIND_INFO = 'info';
public const KIND_TECH_CHECKLIST = 'tech_checklist';
public const FIELD_TEXTAREA = 'textarea';
public const FIELD_DATETIME = 'datetime';
/**
* Ordered fixed process steps.
*
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool}>}>
*/
public function steps(): array
{
return [
[
'key' => 'beauftragung',
'label' => 'Order received',
'description' => 'The security check was sold for one or more customer projects; sales briefs the security officer with all details.',
'kind' => self::KIND_STEP,
],
[
'key' => self::STEP_INFORMATION,
'label' => 'Gather information',
'description' => 'Collect all relevant information from Customer Care / the project lead.',
'kind' => self::KIND_INFO,
'fields' => [
['key' => 'technical_contact', 'label' => 'Technical contact at customer', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'deployment', 'label' => 'Deployment location & type', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'system_info', 'label' => 'Technical system information', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'external_systems', 'label' => 'External systems / API endpoints (exact URLs)', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'special_notes', 'label' => 'Special notes', 'type' => self::FIELD_TEXTAREA, 'required' => false],
],
],
[
'key' => 'scheduling',
'label' => 'Scheduling',
'description' => 'Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'golive_start', 'label' => 'Go-live window start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'golive_end', 'label' => 'Go-live window end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => 'blocker_appointment',
'label' => 'Internal blocker appointment',
'description' => 'Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'blocker_start', 'label' => 'Blocker start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'blocker_end', 'label' => 'Blocker end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => self::STEP_PERFORM_CHECK,
'label' => 'Perform security check',
'description' => 'Work through the product-specific technical checklist.',
'kind' => self::KIND_TECH_CHECKLIST,
],
[
'key' => 'report',
'label' => 'Create report & notify customer',
'description' => 'Create the report for the customer and notify them.',
'kind' => self::KIND_STEP,
],
];
}
/**
* Keys of steps completed manually (everything except the tech-checklist step).
*
* @return list<string>
*/
public function manualStepKeys(): array
{
$keys = [];
foreach ($this->steps() as $step) {
if ($step['kind'] !== self::KIND_TECH_CHECKLIST) {
$keys[] = $step['key'];
}
}
return $keys;
}
/**
* Structured fields captured on a given step (info textareas, datetimes, …).
*
* @return list<array{key: string, label: string, type: string, required: bool}>
*/
public function stepFields(string $stepKey): array
{
foreach ($this->steps() as $step) {
if ($step['key'] === $stepKey) {
return $step['fields'] ?? [];
}
}
return [];
}
/**
* Keys of a step's fields that must be filled before it can be completed.
* (E.g. "Special notes" and the free-text note stay optional.)
*
* @return list<string>
*/
public function requiredFieldKeys(string $stepKey): array
{
$keys = [];
foreach ($this->stepFields($stepKey) as $field) {
if ($field['required']) {
$keys[] = $field['key'];
}
}
return $keys;
}
/**
* Extract the checkable items from a tech-schema snapshot.
*
* @param array<string, mixed> $techSnapshot
* @return list<array{key: string, label: string}>
*/
public static function techCheckItems(array $techSnapshot): array
{
$items = is_array($techSnapshot['items'] ?? null) ? $techSnapshot['items'] : [];
$checks = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
if (($item['type'] ?? '') === 'check' && trim((string) ($item['key'] ?? '')) !== '') {
$checks[] = ['key' => (string) $item['key'], 'label' => (string) ($item['label'] ?? '')];
}
}
return $checks;
}
/**
* Compute progress + derived status purely from state.
*
* @param array<string, mixed> $processState per-step state keyed by step key
* @param array<string, mixed> $techSnapshot frozen tech-schema snapshot
* @param array<string, mixed> $techState per-item state keyed by item key
* @return array{done: int, total: int, percent: int, manual_done: int, manual_total: int, tech_done: int, tech_total: int, step6_done: bool, status: string}
*/
public function computeProgress(array $processState, array $techSnapshot, array $techState, bool $archived = false): array
{
$manualKeys = $this->manualStepKeys();
$manualTotal = count($manualKeys);
$manualDone = 0;
foreach ($manualKeys as $key) {
if (!empty($processState[$key]['done'])) {
$manualDone++;
}
}
$techItems = self::techCheckItems($techSnapshot);
$techTotal = count($techItems);
$techDone = 0;
foreach ($techItems as $item) {
if (!empty($techState[$item['key']]['done'])) {
$techDone++;
}
}
$step6Done = $techTotal === 0 ? true : ($techDone === $techTotal);
$total = $manualTotal + $techTotal;
$done = $manualDone + $techDone;
$percent = $total > 0 ? (int) floor(($done / $total) * 100) : 0;
if ($archived) {
$status = self::STATUS_ARCHIVED;
} elseif ($total > 0 && $done >= $total) {
$status = self::STATUS_COMPLETED;
} elseif ($done > 0) {
$status = self::STATUS_IN_PROGRESS;
} else {
$status = self::STATUS_OPEN;
}
return [
'done' => $done,
'total' => $total,
'percent' => $percent,
'manual_done' => $manualDone,
'manual_total' => $manualTotal,
'tech_done' => $techDone,
'tech_total' => $techTotal,
'step6_done' => $step6Done,
'status' => $status,
];
}
/**
* Whether every step preceding the final "report" step is complete: all
* manual steps except the report itself are done AND the technical checklist
* is fully ticked. Gates the "Generate PDF customer report" action so the
* report can only be produced once the check has actually been carried out.
*
* @param array<string, mixed> $processState
* @param array<string, mixed> $techSnapshot
* @param array<string, mixed> $techState
*/
public function reportPrerequisitesMet(array $processState, array $techSnapshot, array $techState): bool
{
foreach ($this->manualStepKeys() as $key) {
if ($key === self::STEP_REPORT) {
continue;
}
if (empty($processState[$key]['done'])) {
return false;
}
}
return $this->computeProgress($processState, $techSnapshot, $techState)['step6_done'];
}
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_OPEN => 'Open',
self::STATUS_IN_PROGRESS => 'In progress',
self::STATUS_COMPLETED => 'Completed',
self::STATUS_ARCHIVED => 'Archived',
default => $status,
};
}
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_COMPLETED => 'success',
default => 'neutral',
};
}
}

View File

@@ -0,0 +1,296 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
/**
* Orchestrates security checks: creation from the wizard, checklist state updates,
* status derivation, archival and listing.
*
* The fixed process definition + progress/status math live in SecurityCheckProcess;
* the per-product technical checklist is frozen onto the check at creation time.
*
* @api Consumed by Security check pages (list, create wizard, workspace).
*/
class SecurityCheckService
{
public function __construct(
private readonly SecurityCheckRepository $repository,
private readonly SecurityCheckTemplateService $templateService,
private readonly SecurityCheckProcess $process
) {
}
/**
* Create a check from the wizard selection (customer + domain + product/template).
*
* @param array<string, mixed> $input
* @return array{ok: bool, id?: int, errors: array<string, string>}
*/
public function create(int $tenantId, array $input, int $actorUserId): array
{
$debitorNo = trim((string) ($input['debitor_no'] ?? ''));
$debitorName = trim((string) ($input['debitor_name'] ?? ''));
$domainNo = trim((string) ($input['domain_no'] ?? ''));
$domainUrl = trim((string) ($input['domain_url'] ?? ''));
$productCode = trim((string) ($input['product_code'] ?? ''));
$ownerUserId = (int) ($input['owner_user_id'] ?? $actorUserId);
$errors = [];
if ($tenantId <= 0) {
$errors['tenant'] = t('No tenant context');
}
if ($debitorNo === '') {
$errors['debitor_no'] = t('Please select a customer');
}
if ($domainNo === '') {
$errors['domain_no'] = t('Please select a domain');
}
$template = null;
if ($productCode === '') {
$errors['product_code'] = t('Please select a software product');
} else {
$template = $this->templateService->findByCode($tenantId, $productCode);
if ($template === null || (int) ($template['active'] ?? 0) !== 1) {
$errors['product_code'] = t('Selected check template not found');
}
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
/** @var array<string, mixed> $template */
$schema = $this->templateService->decodeSchema($template);
$id = $this->repository->insert($tenantId, [
'debitor_no' => $debitorNo,
'debitor_name' => $debitorName,
'domain_no' => $domainNo,
'domain_url' => $domainUrl,
'product_code' => $productCode,
'product_name' => (string) ($template['product_name'] ?? $productCode),
'template_id' => (int) ($template['id'] ?? 0),
'owner_user_id' => $ownerUserId > 0 ? $ownerUserId : null,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => json_encode((object) []),
'tech_schema_snapshot_json' => json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode((object) []),
'created_by' => $actorUserId,
]);
if ($id === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check could not be created')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* Find a check enriched with decoded state, the frozen tech checklist and progress.
*
* @return array<string, mixed>|null
*/
public function findById(int $tenantId, int $id): ?array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return null;
}
$row['process_state'] = $this->decode($row['process_state_json'] ?? '');
$row['tech_schema_snapshot'] = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$row['tech_state'] = $this->decode($row['tech_state_json'] ?? '');
$row['tech_items'] = SecurityCheckProcess::techCheckItems($row['tech_schema_snapshot']);
$row['progress'] = $this->process->computeProgress(
$row['process_state'],
$row['tech_schema_snapshot'],
$row['tech_state'],
(string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED
);
return $row;
}
/**
* Save the whole checklist workspace form (steps + info fields + tech items).
* Recomputes derived status. Stamps who/when on each item that newly turns done.
*
* @param array{step?: array<string, mixed>, info?: array<string, mixed>, tech?: array<string, mixed>} $input
* @return array{ok: bool, errors: array<string, string>, warning?: string}
*/
public function saveState(int $tenantId, int $id, array $input, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ((string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED) {
return ['ok' => false, 'errors' => ['general' => t('Archived checks cannot be edited')]];
}
$prevProcess = $this->decode($row['process_state_json'] ?? '');
$prevTech = $this->decode($row['tech_state_json'] ?? '');
$techSnapshot = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$now = date('Y-m-d H:i:s');
$stepInput = is_array($input['step'] ?? null) ? $input['step'] : [];
$techInput = is_array($input['tech'] ?? null) ? $input['tech'] : [];
// Each step may carry structured fields (info textareas, datetimes, …).
// A step can only be completed once all of its required fields are filled in
// (e.g. "Special notes" and the free-text note stay optional).
$anyStepGated = false;
$newProcess = [];
foreach ($this->process->manualStepKeys() as $key) {
$fieldDefs = $this->process->stepFields($key);
$submittedFields = is_array($stepInput[$key]['fields'] ?? null) ? $stepInput[$key]['fields'] : [];
$fieldValues = [];
foreach ($fieldDefs as $field) {
$fieldValues[$field['key']] = trim((string) ($submittedFields[$field['key']] ?? ''));
}
$requirementsMet = true;
foreach ($this->process->requiredFieldKeys($key) as $requiredKey) {
if (($fieldValues[$requiredKey] ?? '') === '') {
$requirementsMet = false;
break;
}
}
$done = ($stepInput[$key]['done'] ?? '') !== '';
if ($done && !$requirementsMet) {
$done = false;
$anyStepGated = true;
}
$note = trim((string) ($stepInput[$key]['note'] ?? ''));
$entry = is_array($prevProcess[$key] ?? null) ? $prevProcess[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
if ($fieldDefs !== []) {
$entry['fields'] = $fieldValues;
}
$newProcess[$key] = $entry;
}
$newTech = [];
foreach (SecurityCheckProcess::techCheckItems($techSnapshot) as $item) {
$key = $item['key'];
$done = ($techInput[$key]['done'] ?? '') !== '';
$note = trim((string) ($techInput[$key]['note'] ?? ''));
$entry = is_array($prevTech[$key] ?? null) ? $prevTech[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
$newTech[$key] = $entry;
}
$progress = $this->process->computeProgress($newProcess, $techSnapshot, $newTech, false);
$ok = $this->repository->updateState($tenantId, $id, [
'process_state_json' => json_encode($newProcess, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode($newTech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'status' => $progress['status'],
'updated_by' => $actorUserId,
]);
if (!$ok) {
return ['ok' => false, 'errors' => ['general' => t('Could not save the security check')]];
}
if ($anyStepGated) {
return [
'ok' => true,
'errors' => [],
'warning' => t('Saved. Fill in all required fields to complete the affected steps.'),
];
}
return ['ok' => true, 'errors' => []];
}
/**
* @return array{ok: bool, errors: array<string, string>}
*/
public function setArchived(int $tenantId, int $id, bool $archived, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ($archived) {
$status = SecurityCheckProcess::STATUS_ARCHIVED;
} else {
$progress = $this->process->computeProgress(
$this->decode($row['process_state_json'] ?? ''),
$this->decode($row['tech_schema_snapshot_json'] ?? ''),
$this->decode($row['tech_state_json'] ?? ''),
false
);
$status = $progress['status'];
}
$ok = $this->repository->updateStatus($tenantId, $id, $status, $actorUserId);
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Could not update the status')]];
}
public function delete(int $tenantId, int $id): bool
{
return $this->repository->deleteById($tenantId, $id);
}
/**
* @api Called from checks-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);
}
/**
* Tenant users that can be assigned as the owner of a check.
*
* @api Called from the create wizard
* @return list<array{id: int, display_name: string}>
*/
public function listAssignableUsers(int $tenantId): array
{
return $this->repository->listTenantUsers($tenantId);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function applyCompletion(array $entry, bool $done, int $actorUserId, string $now): array
{
$wasDone = !empty($entry['done']);
if ($done && !$wasDone) {
$entry['done'] = true;
$entry['by'] = $actorUserId;
$entry['at'] = $now;
} elseif (!$done) {
$entry['done'] = false;
unset($entry['by'], $entry['at']);
}
// done && wasDone → keep existing by/at (completion log preserved)
return $entry;
}
/**
* @return array<string, mixed>
*/
private function decode(mixed $json): array
{
$decoded = json_decode((string) $json, true);
return is_array($decoded) ? $decoded : [];
}
}

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, '_');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Module\Security\Service;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* Renders check-template Markdown guidance to sanitized HTML.
*
* Hardened config: raw HTML in the source is escaped (html_input=escape) and
* unsafe links (javascript:, data:, …) are dropped (allow_unsafe_links=false),
* so author-entered content can never inject markup/script into the workspace.
*
* @api Consumed by the security check workspace action to pre-render per-item guidance.
*/
final class SecurityMarkdownGateway
{
private ?GithubFlavoredMarkdownConverter $converter = null;
public function toHtml(string $markdown): string
{
$markdown = trim($markdown);
if ($markdown === '') {
return '';
}
$this->converter ??= new GithubFlavoredMarkdownConverter([
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
return (string) $this->converter->convert($markdown);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
/**
* OAuth2 client_credentials token service for the Security BC gateway.
*
* Tenant-scoped encrypted token cache. Supports v2 endpoints (scope) and legacy
* v1 endpoints (resource) with BC-oriented defaults.
*/
class SecurityOAuthTokenService
{
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly SecurityBcSettingsGateway $settingsGateway,
private readonly SecurityTokenRepository $tokenRepository
) {
}
public function getAccessToken(int $tenantId, ?string $resourceAudience = null): ?string
{
if ($this->settingsGateway->getAuthMode() !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
return null;
}
if ($tenantId <= 0) {
return null;
}
$cached = $this->tokenRepository->findValidToken($tenantId);
if ($cached !== null) {
return $cached;
}
$clientId = trim((string) ($this->settingsGateway->getOAuthClientId() ?? ''));
$clientSecret = trim((string) ($this->settingsGateway->getOAuthClientSecret() ?? ''));
$tokenEndpoint = $this->resolveTokenEndpoint();
if ($clientId === '' || $clientSecret === '' || $tokenEndpoint === '') {
return null;
}
$tokenResponse = $this->requestToken($tokenEndpoint, $clientId, $clientSecret, $resourceAudience);
if (!is_array($tokenResponse)) {
return null;
}
$accessToken = trim((string) ($tokenResponse['access_token'] ?? ''));
if ($accessToken === '') {
return null;
}
$expiresIn = (int) ($tokenResponse['expires_in'] ?? 0);
if ($expiresIn <= 0) {
$expiresIn = (int) ($tokenResponse['ext_expires_in'] ?? 0);
}
if ($expiresIn <= 0) {
$expiresIn = 300;
}
$expiresAt = new \DateTimeImmutable('+' . max(60, $expiresIn - 60) . ' seconds');
$this->tokenRepository->storeToken($tenantId, $accessToken, $expiresAt);
return $accessToken;
}
private function resolveTokenEndpoint(): string
{
$endpoint = trim((string) ($this->settingsGateway->getOAuthTokenEndpoint() ?? ''));
if ($endpoint === '') {
return '';
}
$tenantId = trim((string) ($this->settingsGateway->getOAuthTenantId() ?? ''));
if ($tenantId !== '') {
$endpoint = str_replace(['{tenant}', '{tenant_id}'], $tenantId, $endpoint);
}
return $endpoint;
}
/**
* @return array<string,mixed>|null
*/
private function requestToken(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): ?array
{
foreach ($this->buildPayloadCandidates($endpoint, $clientId, $clientSecret, $resourceAudience) as $payload) {
$result = $this->requestTokenWithPayload($endpoint, $payload);
if ($result !== null) {
return $result;
}
}
return null;
}
/**
* @return array<int, array<string, string>>
*/
private function buildPayloadCandidates(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): array
{
$basePayload = [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
];
$payloads = [];
$isV2Endpoint = str_contains(strtolower($endpoint), '/oauth2/v2.0/token');
$audience = trim((string) ($resourceAudience ?? ''));
if ($isV2Endpoint) {
$scopes = ['https://api.businesscentral.dynamics.com/.default'];
if ($audience !== '') {
$scopes[] = rtrim($audience, '/') . '/.default';
}
foreach (array_values(array_unique($scopes)) as $scope) {
$payloads[] = $basePayload + ['scope' => $scope];
}
} else {
$resources = ['https://api.businesscentral.dynamics.com'];
if ($audience !== '') {
$resources[] = $audience;
}
foreach (array_values(array_unique($resources)) as $resource) {
$payloads[] = $basePayload + ['resource' => $resource];
}
}
$payloads[] = $basePayload;
return $payloads;
}
/**
* @param array<string,string> $payload
* @return array<string,mixed>|null
*/
private function requestTokenWithPayload(string $endpoint, array $payload): ?array
{
$ch = curl_init();
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload, '', '&', PHP_QUERY_RFC3986),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
return null;
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
}

View File

@@ -0,0 +1,282 @@
<?php
namespace MintyPHP\Module\Security\Service;
use Dompdf\Dompdf;
use Dompdf\Options;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Tenant\TenantLogoService;
use Throwable;
/**
* Renders the customer-facing PDF report for a completed security check.
*
* Follows the core Dompdf pattern (see UserAccessPdfService): build a flat,
* render-ready context from the check, hydrate a self-contained PHTML template
* to an HTML string, then rasterise it with Dompdf (remote + PHP execution
* disabled). The context builder is pure (no I/O) so it is fully unit-testable;
* only the render step touches the filesystem / Dompdf.
*
* @api Consumed by the security/checks/report download action.
*/
final class SecurityReportPdfService
{
public function __construct(
private readonly SecurityCheckProcess $process,
private readonly BrandingLogoService $brandingLogoService,
private readonly ?TenantLogoService $tenantLogoService = null
) {
}
/**
* Render the customer report PDF. Returns raw PDF bytes, or '' if the
* renderer is unavailable, the template is missing, or rendering fails.
*
* @param array<string, mixed> $check Enriched check row (SecurityCheckService::findById)
* @param array{locale?: string, app_name?: string, tenant_uuid?: string} $options
*/
public function renderCustomerReportPdf(array $check, array $options = []): string
{
if (!class_exists(Dompdf::class) || !class_exists(Options::class)) {
return '';
}
$report = $this->buildReportContext($check);
$report['app_name'] = trim((string) ($options['app_name'] ?? ''));
$report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? ''));
$report['generated_at'] = date('Y-m-d H:i');
$html = $this->renderTemplate($report);
if ($html === '') {
return '';
}
try {
$options = new Options();
// Keep rendering deterministic: no remote fetches, no embedded PHP.
$options->setIsRemoteEnabled(false);
$options->setIsPhpEnabled(false);
$options->setDefaultFont('DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4');
$dompdf->render();
$dompdf->addInfo('Title', (string) $report['title']);
$dompdf->addInfo('Subject', (string) $report['title']);
return (string) $dompdf->output();
} catch (Throwable) {
return '';
}
}
/**
* A filename-safe slug describing the report (customer + domain), e.g.
* "security-check-report-acme-acme-de". Always returns a non-empty base.
*
* @param array<string, mixed> $check
*/
public function buildReportFilename(array $check): string
{
$parts = array_filter([
(string) ($check['debitor_name'] ?? $check['debitor_no'] ?? ''),
(string) ($check['domain_url'] ?? $check['domain_no'] ?? ''),
]);
$slug = self::slugify(implode('-', $parts));
return 'security-check-report' . ($slug !== '' ? '-' . $slug : '') . '.pdf';
}
/**
* Build the flat, render-ready report context from an enriched check row.
* Pure transform (no filesystem / network) — translation via t() only.
*
* @param array<string, mixed> $check
* @return array<string, mixed>
*/
public function buildReportContext(array $check): array
{
$processState = is_array($check['process_state'] ?? null) ? $check['process_state'] : [];
$techState = is_array($check['tech_state'] ?? null) ? $check['tech_state'] : [];
$snapshot = is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [];
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
return [
'title' => t('Security check report'),
'customer_name' => (string) ($check['debitor_name'] ?? ''),
'customer_no' => (string) ($check['debitor_no'] ?? ''),
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
'status' => $status,
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
'summary' => [
'done' => (int) ($progress['done'] ?? 0),
'total' => (int) ($progress['total'] ?? 0),
'percent' => (int) ($progress['percent'] ?? 0),
'tech_done' => (int) ($progress['tech_done'] ?? 0),
'tech_total' => (int) ($progress['tech_total'] ?? 0),
],
'steps' => $this->buildSteps($processState),
'tech_sections' => $this->buildTechSections($snapshot, $techState),
];
}
/**
* High-level process milestones (label + completion date), excluding the
* internal field values and per-step notes that are not customer-facing.
*
* @param array<string, mixed> $processState
* @return list<array{number: int, label: string, done: bool, completed_at: string}>
*/
private function buildSteps(array $processState): array
{
$steps = [];
$number = 0;
foreach ($this->process->steps() as $step) {
$number++;
if ($step['kind'] === SecurityCheckProcess::KIND_TECH_CHECKLIST) {
continue;
}
$key = $step['key'];
$entry = is_array($processState[$key] ?? null) ? $processState[$key] : [];
$steps[] = [
'number' => $number,
'label' => t($step['label']),
'done' => !empty($entry['done']),
'completed_at' => trim((string) ($entry['at'] ?? '')),
];
}
return $steps;
}
/**
* The frozen technical checklist grouped by section, each item carrying its
* pass/fail state and any finding note. Items before the first section land
* in an unlabelled leading group.
*
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $techState
* @return list<array{label: string, items: list<array{label: string, done: bool, note: string}>}>
*/
private function buildTechSections(array $snapshot, array $techState): array
{
$items = is_array($snapshot['items'] ?? null) ? $snapshot['items'] : [];
$sections = [];
$current = ['label' => '', 'items' => []];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
if (($item['type'] ?? '') === 'section') {
if ($current['items'] !== []) {
$sections[] = $current;
}
$current = ['label' => (string) ($item['label'] ?? ''), 'items' => []];
continue;
}
$key = trim((string) ($item['key'] ?? ''));
if ($key === '') {
continue;
}
$entry = is_array($techState[$key] ?? null) ? $techState[$key] : [];
$current['items'][] = [
'label' => (string) ($item['label'] ?? ''),
'done' => !empty($entry['done']),
'note' => trim((string) ($entry['note'] ?? '')),
];
}
if ($current['items'] !== []) {
$sections[] = $current;
}
return $sections;
}
/**
* @param array<string, mixed> $report
*/
private function renderTemplate(array $report): string
{
$templatePath = dirname(__DIR__, 4) . '/templates/pdf/customer-report.phtml';
if (!is_file($templatePath)) {
return '';
}
// Isolated scope: the template only sees $report, never this service's state.
$render = static function (string $__path, array $report): string {
ob_start();
include $__path;
return (string) ob_get_clean();
};
return $render($templatePath, $report);
}
/**
* Resolve a logo data URI: tenant light-logo → global branding logo →
* static brand asset → transparent pixel. Mirrors the core PDF pattern; the
* PDF has no theme context, so the light variant is always used.
*/
private function resolveLogoDataUri(string $tenantUuid): string
{
$path = '';
$mime = '';
if ($this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) {
$logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->tenantLogoService->detectMime($logoPath);
}
}
if ($path === '' && $this->brandingLogoService->hasLogo()) {
$logoPath = $this->brandingLogoService->findLogoPath(128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->brandingLogoService->detectMime($logoPath);
}
}
if ($path === '') {
$fallback = dirname(__DIR__, 6) . '/web/brand/logo.svg';
if (is_file($fallback)) {
$path = $fallback;
$mime = 'image/svg+xml';
}
}
if ($path === '' || !is_file($path)) {
return self::transparentPixelDataUri();
}
$content = @file_get_contents($path);
if ($content === false || $content === '') {
return self::transparentPixelDataUri();
}
return 'data:' . ($mime !== '' ? $mime : 'image/png') . ';base64,' . base64_encode($content);
}
private static function transparentPixelDataUri(): string
{
// Smallest safe placeholder so the <img> tag stays valid when no logo exists.
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==';
}
private static function slugify(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
$value = (string) preg_replace('/[^a-z0-9]+/', '-', $value);
return trim($value, '-');
}
}