First version of the security module
This commit is contained in:
@@ -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,
|
||||
]];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 2–3h 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 1–2 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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, '_');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user