First version of the security module
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user