First version of the security module
This commit is contained in:
112
modules/security/CLAUDE.md
Normal file
112
modules/security/CLAUDE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# CLAUDE.md — Security Module
|
||||
|
||||
Self-contained MintyPHP module. Namespace: `MintyPHP\Module\Security\*`. **Standalone** —
|
||||
`requires: []`, no dependency on helpdesk. All changes stay inside `modules/security/`.
|
||||
|
||||
After any manifest change (route, permission, slot): `docker compose exec php php bin/console module:sync`
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
Lets the security officer run **security checks** for customers. A check is created via a wizard
|
||||
(select customer → domain → software product), then tracked on a checklist workspace that combines:
|
||||
|
||||
1. A **fixed 7-step internal process** (`SecurityCheckProcess`, identical for every check), and
|
||||
2. A **per-product technical checklist** defined as a reusable **template** (the product catalog is
|
||||
owned by this module — the templates *are* the product list).
|
||||
|
||||
Overall status (`open` → `in_progress` → `completed`, plus manual `archived`) is **derived** from
|
||||
checklist progress. Each step/item records who completed it and when (completion log).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/Module/Security/
|
||||
SecurityAuthorizationPolicy.php — ability/permission constants + authorize()
|
||||
SecurityContainerRegistrar.php — all DI bindings (add new services here)
|
||||
Providers/SecurityLayoutProvider.php — resolves can_* flags for the sidebar
|
||||
Repository/ — SQL only (tenant-scoped, prepared statements)
|
||||
Service/
|
||||
SecurityCheckProcess.php — fixed 7-step process + progress/status math (pure, no DI)
|
||||
SecurityCheckService.php — check CRUD, saveState, status derivation, archive
|
||||
SecurityCheckTemplateService.php— template CRUD + tech-schema validation/slugify
|
||||
SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted)
|
||||
SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache
|
||||
SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer)
|
||||
SecurityReportPdfService.php — customer PDF report (Dompdf); pure context builder + render
|
||||
pages/security/ — actions (*.php) + views (*.phtml)
|
||||
checks/report($id).php — streams the customer report PDF (gated on steps 1–5 complete)
|
||||
templates/aside-security-panel.phtml— sidebar navigation
|
||||
templates/pdf/customer-report.phtml — customer report PDF template (hydrated by SecurityReportPdfService)
|
||||
i18n/default_de.json + default_en.json — keep both in sync (identical key sets)
|
||||
migrations/ — 003 SQL files (module:migrate)
|
||||
web/css/security.css | web/js/ — module styles + runtime components + page scripts
|
||||
tests/Module/Security/Service/ — 7 test files (happy path + edge case)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permissions (6)
|
||||
|
||||
All constants in `SecurityAuthorizationPolicy`:
|
||||
|
||||
| Constant | Key |
|
||||
|---|---|
|
||||
| `ABILITY_ACCESS` | `security.access` — gates the main-menu entry |
|
||||
| `ABILITY_CHECKS_VIEW` | `security.checks.view` |
|
||||
| `ABILITY_CHECKS_CREATE` | `security.checks.create` — create + edit the checklist |
|
||||
| `ABILITY_CHECKS_MANAGE` | `security.checks.manage` — archive, delete |
|
||||
| `ABILITY_TEMPLATES_MANAGE` | `security.templates.manage` |
|
||||
| `ABILITY_SETTINGS_MANAGE` | `security.settings.manage` |
|
||||
|
||||
Gate every action with `Guard::requireAbilityOrForbidden(...)`; tenant scope enforced in repositories.
|
||||
|
||||
---
|
||||
|
||||
## DB schema
|
||||
|
||||
| Table | Key columns |
|
||||
|---|---|
|
||||
| `security_check_templates` | tenant_id, product_code (uniq), product_name, tech_schema_json, active, version |
|
||||
| `security_checks` | tenant_id, debitor_no, domain_no, product_code, template_id, owner_user_id, status, process_state_json, tech_schema_snapshot_json, tech_state_json |
|
||||
| `security_oauth_token_cache` | tenant_id, access_token_encrypted, expires_at |
|
||||
|
||||
`tech_schema_snapshot_json` freezes the template's checklist onto the check at creation time, so later
|
||||
template edits never mutate in-flight checks. JSON state shapes:
|
||||
- tech schema: `{version, items:[ {type:"section",label}, {type:"check",key,label,hint?} ]}`
|
||||
- process_state: `{<stepKey>: {done, by, at, note, fields?}}`
|
||||
- tech_state: `{<itemKey>: {done, by, at, note}}`
|
||||
|
||||
---
|
||||
|
||||
## Routes / pages
|
||||
|
||||
Main menu entry via the `aside.tab_panel` slot (icon `bi-shield-check`, permission `security.access`).
|
||||
Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}`
|
||||
(workspace), `security/checks/report/{id}` (streams the customer PDF report),
|
||||
`security/templates` (+create/edit/{id}), `security/settings`, plus data endpoints
|
||||
`security/checks-data`, `security/templates-data`, `security/customer-search-data`,
|
||||
`security/customer-domains-data`, `security/settings/test-connection-data`.
|
||||
|
||||
## JS runtime components
|
||||
|
||||
`data-app-component` on the container: `security-customer-domain-select` (wizard domain picker),
|
||||
`security-check-workspace` (live progress), `security-template-schema-editor` (checklist editor),
|
||||
`security-settings` (auth-mode toggle + connection test). List pages load their own
|
||||
`web/js/pages/*-index.js` via `<script type="module">`.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
`docker compose exec php vendor/bin/phpunit modules/security/tests/`
|
||||
Pattern: mock repositories, `->willReturn(...)` / `->willReturnCallback(...)` to capture args
|
||||
(avoid `->with()`). Every new/changed Service or Gateway needs happy path + edge case.
|
||||
|
||||
## Deferred (not built)
|
||||
|
||||
Internal report PDF (the customer report is built — see `SecurityReportPdfService`),
|
||||
per-tenant BC connection overrides, per-step assignment, revision history.
|
||||
176
modules/security/i18n/default_de.json
Normal file
176
modules/security/i18n/default_de.json
Normal file
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"Security": "Security",
|
||||
"Security checks": "Security Checks",
|
||||
"Security check": "Security Check",
|
||||
"Security settings": "Security-Einstellungen",
|
||||
"Check templates": "Check-Vorlagen",
|
||||
"Operations": "Durchführung",
|
||||
"Administration": "Verwaltung",
|
||||
"Settings": "Einstellungen",
|
||||
"Home": "Start",
|
||||
"Customer": "Kunde",
|
||||
"Domain": "Domain",
|
||||
"Software product": "Software-Produkt",
|
||||
"Owner": "Verantwortlich",
|
||||
"Status": "Status",
|
||||
"Created": "Erstellt",
|
||||
"Updated": "Aktualisiert",
|
||||
"Product": "Produkt",
|
||||
"Code": "Code",
|
||||
"Checklist items": "Checklisten-Punkte",
|
||||
"Active": "Aktiv",
|
||||
"Yes": "Ja",
|
||||
"No": "Nein",
|
||||
"Search": "Suche",
|
||||
"Progress": "Fortschritt",
|
||||
"Open": "Offen",
|
||||
"In progress": "In Bearbeitung",
|
||||
"Completed": "Abgeschlossen",
|
||||
"Archived": "Archiviert",
|
||||
"Complete": "Vollständig",
|
||||
"Done": "Erledigt",
|
||||
"New security check": "Neuer Security Check",
|
||||
"Create security check": "Security Check anlegen",
|
||||
"Select customer, domain and product": "Kunde, Domain und Produkt auswählen",
|
||||
"Choose the customer, domain and software product this security check applies to.": "Wählen Sie Kunde, Domain und Software-Produkt für diesen Security Check.",
|
||||
"Search by name or number...": "Nach Name oder Nummer suchen...",
|
||||
"No customers found": "Keine Kunden gefunden",
|
||||
"Search failed": "Suche fehlgeschlagen",
|
||||
"Select a customer first...": "Bitte zuerst einen Kunden auswählen...",
|
||||
"Loading domains...": "Domains werden geladen...",
|
||||
"Please select...": "Bitte auswählen...",
|
||||
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
|
||||
"No domains found for this customer. Enter domain name manually:": "Keine Domains für diesen Kunden gefunden. Domain manuell eingeben:",
|
||||
"Failed to load domains": "Domains konnten nicht geladen werden",
|
||||
"Domain name (e.g. example.de)": "Domain-Name (z. B. example.de)",
|
||||
"URL (optional, e.g. https://example.de)": "URL (optional, z. B. https://example.de)",
|
||||
"No check templates found. Create a check template first.": "Keine Check-Vorlagen gefunden. Bitte zuerst eine Vorlage anlegen.",
|
||||
"Back to list": "Zurück zur Liste",
|
||||
"Cancel": "Abbrechen",
|
||||
"Save": "Speichern",
|
||||
"Save & close": "Speichern & schließen",
|
||||
"Note (optional)": "Notiz (optional)",
|
||||
"Danger zone": "Gefahrenzone",
|
||||
"Archive check": "Check archivieren",
|
||||
"Reopen check": "Check wieder öffnen",
|
||||
"Delete check": "Check löschen",
|
||||
"Delete this security check? This cannot be undone.": "Diesen Security Check löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"This security check is archived and read-only.": "Dieser Security Check ist archiviert und schreibgeschützt.",
|
||||
"This product has no technical checklist items defined yet.": "Für dieses Produkt sind noch keine technischen Checklisten-Punkte definiert.",
|
||||
"%d of %d completed": "%d von %d erledigt",
|
||||
"Order received": "Beauftragung",
|
||||
"Gather information": "Informationsbeschaffung",
|
||||
"Scheduling": "Terminfindung",
|
||||
"Internal blocker appointment": "Interner Block-Termin",
|
||||
"Perform security check": "Security Check durchführen",
|
||||
"Create report & notify customer": "Protokoll/Bericht erstellen & Kunden benachrichtigen",
|
||||
"The security check was sold for one or more customer projects; sales briefs the security officer with all details.": "Der Security Check wurde für ein oder mehrere Projekte des Kunden verkauft; der Verkäufer übergibt dem Security-Beauftragten alle Details.",
|
||||
"Collect all relevant information from Customer Care / the project lead.": "Alle wichtigen Informationen vom Customer Care bzw. Projektleiter einholen.",
|
||||
"Agree internally and with the customer on the check date. Only the 2–3h go-live window is relevant to the customer.": "Den Termin intern und mit dem Kunden abstimmen. Für den Kunden ist nur das 2–3-stündige Live-Stellungs-Fenster relevant.",
|
||||
"Create a 1–2 day internal blocker; invite Customer Care, Customizing and Sales.": "Einen internen Blocker-Termin (1–2 Tage) erstellen; Customer Care, Customizing und Verkäufer einladen.",
|
||||
"Work through the product-specific technical checklist.": "Die produktspezifische technische Checkliste abarbeiten.",
|
||||
"Create the report for the customer and notify them.": "Den Bericht für den Kunden erstellen und ihn benachrichtigen.",
|
||||
"Technical contact at customer": "Techn. Ansprechpartner beim Kunden",
|
||||
"Deployment location & type": "Deployment-Ort und -Art",
|
||||
"Technical system information": "Technische Informationen über das System",
|
||||
"External systems / API endpoints (exact URLs)": "Externe Systeme / API-Endpoints (genaue URLs)",
|
||||
"Special notes": "Besonderheiten",
|
||||
"Go-live window start": "Go-live-Fenster Beginn",
|
||||
"Go-live window end": "Go-live-Fenster Ende",
|
||||
"Blocker start": "Block-Termin Beginn",
|
||||
"Blocker end": "Block-Termin Ende",
|
||||
"New template": "Neue Vorlage",
|
||||
"New check template": "Neue Check-Vorlage",
|
||||
"Create a check template": "Check-Vorlage anlegen",
|
||||
"A template represents one software product and its technical checklist. You can add checklist items after creating it.": "Eine Vorlage steht für ein Software-Produkt und dessen technische Checkliste. Checklisten-Punkte können nach dem Anlegen hinzugefügt werden.",
|
||||
"Product code": "Produkt-Code",
|
||||
"Product name": "Produktname",
|
||||
"e.g. intranet, mysyde-cms": "z. B. intranet, mysyde-cms",
|
||||
"e.g. Intranet, Mysyde CMS": "z. B. Intranet, Mysyde CMS",
|
||||
"Create template": "Vorlage anlegen",
|
||||
"Edit template": "Vorlage bearbeiten",
|
||||
"The product code cannot be changed after creation.": "Der Produkt-Code kann nach dem Anlegen nicht mehr geändert werden.",
|
||||
"Active (selectable when creating a security check)": "Aktiv (beim Anlegen eines Security Checks auswählbar)",
|
||||
"Technical checklist": "Technische Checkliste",
|
||||
"Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.": "Definieren Sie die produktspezifischen technischen Checks. Abschnitte gruppieren die Punkte. Die Punkte werden beim Anlegen eines Security Checks eingefroren.",
|
||||
"Section": "Abschnitt",
|
||||
"Check item": "Prüfpunkt",
|
||||
"Add section": "Abschnitt hinzufügen",
|
||||
"Add check item": "Prüfpunkt hinzufügen",
|
||||
"Remove": "Entfernen",
|
||||
"Move up": "Nach oben",
|
||||
"Move down": "Nach unten",
|
||||
"Section title": "Abschnitts-Titel",
|
||||
"Check item label": "Bezeichnung des Prüfpunkts",
|
||||
"Hint (optional)": "Hinweis (optional)",
|
||||
"Markdown guidance (optional) — shown in the checklist": "Markdown-Hinweis (optional) — wird in der Checkliste angezeigt",
|
||||
"Guidance": "Hinweise",
|
||||
"No checklist items yet. Add a check item to get started.": "Noch keine Checklisten-Punkte. Fügen Sie einen Prüfpunkt hinzu.",
|
||||
"Business Central connection": "Business-Central-Verbindung",
|
||||
"Authentication mode": "Authentifizierungs-Modus",
|
||||
"Basic authentication": "Basic-Authentifizierung",
|
||||
"OAuth2 (client credentials)": "OAuth2 (Client Credentials)",
|
||||
"OData base URL": "OData-Basis-URL",
|
||||
"Company name": "Firmenname",
|
||||
"Username": "Benutzername",
|
||||
"Password": "Passwort",
|
||||
"Token endpoint": "Token-Endpoint",
|
||||
"Directory (tenant) ID": "Verzeichnis-(Tenant-)ID",
|
||||
"Client ID": "Client-ID",
|
||||
"Client secret": "Client-Secret",
|
||||
"•••••••• (leave blank to keep)": "•••••••• (leer lassen, um beizubehalten)",
|
||||
"Connection not fully configured": "Verbindung nicht vollständig konfiguriert",
|
||||
"Test connection": "Verbindung testen",
|
||||
"Save your settings first, then test the connection to Business Central.": "Speichern Sie zuerst Ihre Einstellungen und testen Sie dann die Verbindung zu Business Central.",
|
||||
"Testing...": "Test läuft...",
|
||||
"Connection successful": "Verbindung erfolgreich",
|
||||
"Connection failed": "Verbindung fehlgeschlagen",
|
||||
"Settings saved": "Einstellungen gespeichert",
|
||||
"OData Base URL must use HTTPS": "Die OData-Basis-URL muss HTTPS verwenden",
|
||||
"Token endpoint must use HTTPS": "Der Token-Endpoint muss HTTPS verwenden",
|
||||
"Form expired, please try again": "Formular abgelaufen, bitte erneut versuchen",
|
||||
"Permission denied": "Zugriff verweigert",
|
||||
"No tenant context": "Kein Mandanten-Kontext",
|
||||
"Please select a customer": "Bitte einen Kunden auswählen",
|
||||
"Please select a domain": "Bitte eine Domain auswählen",
|
||||
"Please select a software product": "Bitte ein Software-Produkt auswählen",
|
||||
"Selected check template not found": "Ausgewählte Check-Vorlage nicht gefunden",
|
||||
"Security check could not be created": "Security Check konnte nicht angelegt werden",
|
||||
"Security check created": "Security Check angelegt",
|
||||
"Security check not found": "Security Check nicht gefunden",
|
||||
"Security check saved": "Security Check gespeichert",
|
||||
"Saved. Fill in all required fields to complete the affected steps.": "Gespeichert. Bitte alle Pflichtfelder ausfüllen, um die betreffenden Schritte abzuschließen.",
|
||||
"Fill in all required fields to complete this step.": "Bitte alle Pflichtfelder ausfüllen, um diesen Schritt abzuschließen.",
|
||||
"Security check deleted": "Security Check gelöscht",
|
||||
"Security check archived": "Security Check archiviert",
|
||||
"Security check reopened": "Security Check wieder geöffnet",
|
||||
"Archived checks cannot be edited": "Archivierte Checks können nicht bearbeitet werden",
|
||||
"This security check cannot be edited": "Dieser Security Check kann nicht bearbeitet werden",
|
||||
"Could not save the security check": "Security Check konnte nicht gespeichert werden",
|
||||
"Could not update the status": "Status konnte nicht aktualisiert werden",
|
||||
"Product code cannot be empty": "Produkt-Code darf nicht leer sein",
|
||||
"Product code is invalid": "Produkt-Code ist ungültig",
|
||||
"A template with this product code already exists": "Eine Vorlage mit diesem Produkt-Code existiert bereits",
|
||||
"Product name cannot be empty": "Produktname darf nicht leer sein",
|
||||
"Template could not be created": "Vorlage konnte nicht angelegt werden",
|
||||
"Template could not be updated": "Vorlage konnte nicht aktualisiert werden",
|
||||
"Template not found": "Vorlage nicht gefunden",
|
||||
"Template created": "Vorlage angelegt",
|
||||
"Template saved": "Vorlage gespeichert",
|
||||
"Maximum %d items allowed": "Maximal %d Punkte erlaubt",
|
||||
"Item %d has an invalid type": "Punkt %d hat einen ungültigen Typ",
|
||||
"Item %d is missing a label": "Punkt %d fehlt eine Bezeichnung",
|
||||
"Item %d has an invalid key": "Punkt %d hat einen ungültigen Schlüssel",
|
||||
"Could not encode the checklist": "Die Checkliste konnte nicht kodiert werden",
|
||||
"Checklist could not be saved": "Die Checkliste konnte nicht gespeichert werden",
|
||||
"Generate PDF customer report": "PDF-Kundenbericht erstellen",
|
||||
"Opens in a new tab and reflects the last saved state.": "Öffnet sich in einem neuen Tab und gibt den zuletzt gespeicherten Stand wieder.",
|
||||
"Complete and save all previous steps to enable the customer report.": "Alle vorherigen Schritte abschließen und speichern, um den Kundenbericht freizuschalten.",
|
||||
"Complete all previous steps before generating the customer report.": "Schließen Sie alle vorherigen Schritte ab, bevor Sie den Kundenbericht erstellen.",
|
||||
"Security check report": "Sicherheitscheck-Bericht",
|
||||
"Generated on": "Erstellt am",
|
||||
"Result": "Ergebnis",
|
||||
"Process": "Ablauf",
|
||||
"Not completed": "Nicht abgeschlossen",
|
||||
"%d of %d checks completed": "%d von %d Prüfungen abgeschlossen"
|
||||
}
|
||||
176
modules/security/i18n/default_en.json
Normal file
176
modules/security/i18n/default_en.json
Normal file
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"Security": "Security",
|
||||
"Security checks": "Security checks",
|
||||
"Security check": "Security check",
|
||||
"Security settings": "Security settings",
|
||||
"Check templates": "Check templates",
|
||||
"Operations": "Operations",
|
||||
"Administration": "Administration",
|
||||
"Settings": "Settings",
|
||||
"Home": "Home",
|
||||
"Customer": "Customer",
|
||||
"Domain": "Domain",
|
||||
"Software product": "Software product",
|
||||
"Owner": "Owner",
|
||||
"Status": "Status",
|
||||
"Created": "Created",
|
||||
"Updated": "Updated",
|
||||
"Product": "Product",
|
||||
"Code": "Code",
|
||||
"Checklist items": "Checklist items",
|
||||
"Active": "Active",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
"Search": "Search",
|
||||
"Progress": "Progress",
|
||||
"Open": "Open",
|
||||
"In progress": "In progress",
|
||||
"Completed": "Completed",
|
||||
"Archived": "Archived",
|
||||
"Complete": "Complete",
|
||||
"Done": "Done",
|
||||
"New security check": "New security check",
|
||||
"Create security check": "Create security check",
|
||||
"Select customer, domain and product": "Select customer, domain and product",
|
||||
"Choose the customer, domain and software product this security check applies to.": "Choose the customer, domain and software product this security check applies to.",
|
||||
"Search by name or number...": "Search by name or number...",
|
||||
"No customers found": "No customers found",
|
||||
"Search failed": "Search failed",
|
||||
"Select a customer first...": "Select a customer first...",
|
||||
"Loading domains...": "Loading domains...",
|
||||
"Please select...": "Please select...",
|
||||
"No domains found for this customer": "No domains found for this customer",
|
||||
"No domains found for this customer. Enter domain name manually:": "No domains found for this customer. Enter domain name manually:",
|
||||
"Failed to load domains": "Failed to load domains",
|
||||
"Domain name (e.g. example.de)": "Domain name (e.g. example.de)",
|
||||
"URL (optional, e.g. https://example.de)": "URL (optional, e.g. https://example.de)",
|
||||
"No check templates found. Create a check template first.": "No check templates found. Create a check template first.",
|
||||
"Back to list": "Back to list",
|
||||
"Cancel": "Cancel",
|
||||
"Save": "Save",
|
||||
"Save & close": "Save & close",
|
||||
"Note (optional)": "Note (optional)",
|
||||
"Danger zone": "Danger zone",
|
||||
"Archive check": "Archive check",
|
||||
"Reopen check": "Reopen check",
|
||||
"Delete check": "Delete check",
|
||||
"Delete this security check? This cannot be undone.": "Delete this security check? This cannot be undone.",
|
||||
"This security check is archived and read-only.": "This security check is archived and read-only.",
|
||||
"This product has no technical checklist items defined yet.": "This product has no technical checklist items defined yet.",
|
||||
"%d of %d completed": "%d of %d completed",
|
||||
"Order received": "Order received",
|
||||
"Gather information": "Gather information",
|
||||
"Scheduling": "Scheduling",
|
||||
"Internal blocker appointment": "Internal blocker appointment",
|
||||
"Perform security check": "Perform security check",
|
||||
"Create report & notify customer": "Create report & notify customer",
|
||||
"The security check was sold for one or more customer projects; sales briefs the security officer with all details.": "The security check was sold for one or more customer projects; sales briefs the security officer with all details.",
|
||||
"Collect all relevant information from Customer Care / the project lead.": "Collect all relevant information from Customer Care / the project lead.",
|
||||
"Agree internally and with the customer on the check date. Only the 2–3h go-live window is relevant to the customer.": "Agree internally and with the customer on the check date. Only the 2–3h go-live window is relevant to the customer.",
|
||||
"Create a 1–2 day internal blocker; invite Customer Care, Customizing and Sales.": "Create a 1–2 day internal blocker; invite Customer Care, Customizing and Sales.",
|
||||
"Work through the product-specific technical checklist.": "Work through the product-specific technical checklist.",
|
||||
"Create the report for the customer and notify them.": "Create the report for the customer and notify them.",
|
||||
"Technical contact at customer": "Technical contact at customer",
|
||||
"Deployment location & type": "Deployment location & type",
|
||||
"Technical system information": "Technical system information",
|
||||
"External systems / API endpoints (exact URLs)": "External systems / API endpoints (exact URLs)",
|
||||
"Special notes": "Special notes",
|
||||
"Go-live window start": "Go-live window start",
|
||||
"Go-live window end": "Go-live window end",
|
||||
"Blocker start": "Blocker start",
|
||||
"Blocker end": "Blocker end",
|
||||
"New template": "New template",
|
||||
"New check template": "New check template",
|
||||
"Create a check template": "Create a check template",
|
||||
"A template represents one software product and its technical checklist. You can add checklist items after creating it.": "A template represents one software product and its technical checklist. You can add checklist items after creating it.",
|
||||
"Product code": "Product code",
|
||||
"Product name": "Product name",
|
||||
"e.g. intranet, mysyde-cms": "e.g. intranet, mysyde-cms",
|
||||
"e.g. Intranet, Mysyde CMS": "e.g. Intranet, Mysyde CMS",
|
||||
"Create template": "Create template",
|
||||
"Edit template": "Edit template",
|
||||
"The product code cannot be changed after creation.": "The product code cannot be changed after creation.",
|
||||
"Active (selectable when creating a security check)": "Active (selectable when creating a security check)",
|
||||
"Technical checklist": "Technical checklist",
|
||||
"Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.": "Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.",
|
||||
"Section": "Section",
|
||||
"Check item": "Check item",
|
||||
"Add section": "Add section",
|
||||
"Add check item": "Add check item",
|
||||
"Remove": "Remove",
|
||||
"Move up": "Move up",
|
||||
"Move down": "Move down",
|
||||
"Section title": "Section title",
|
||||
"Check item label": "Check item label",
|
||||
"Hint (optional)": "Hint (optional)",
|
||||
"Markdown guidance (optional) — shown in the checklist": "Markdown guidance (optional) — shown in the checklist",
|
||||
"Guidance": "Guidance",
|
||||
"No checklist items yet. Add a check item to get started.": "No checklist items yet. Add a check item to get started.",
|
||||
"Business Central connection": "Business Central connection",
|
||||
"Authentication mode": "Authentication mode",
|
||||
"Basic authentication": "Basic authentication",
|
||||
"OAuth2 (client credentials)": "OAuth2 (client credentials)",
|
||||
"OData base URL": "OData base URL",
|
||||
"Company name": "Company name",
|
||||
"Username": "Username",
|
||||
"Password": "Password",
|
||||
"Token endpoint": "Token endpoint",
|
||||
"Directory (tenant) ID": "Directory (tenant) ID",
|
||||
"Client ID": "Client ID",
|
||||
"Client secret": "Client secret",
|
||||
"•••••••• (leave blank to keep)": "•••••••• (leave blank to keep)",
|
||||
"Connection not fully configured": "Connection not fully configured",
|
||||
"Test connection": "Test connection",
|
||||
"Save your settings first, then test the connection to Business Central.": "Save your settings first, then test the connection to Business Central.",
|
||||
"Testing...": "Testing...",
|
||||
"Connection successful": "Connection successful",
|
||||
"Connection failed": "Connection failed",
|
||||
"Settings saved": "Settings saved",
|
||||
"OData Base URL must use HTTPS": "OData Base URL must use HTTPS",
|
||||
"Token endpoint must use HTTPS": "Token endpoint must use HTTPS",
|
||||
"Form expired, please try again": "Form expired, please try again",
|
||||
"Permission denied": "Permission denied",
|
||||
"No tenant context": "No tenant context",
|
||||
"Please select a customer": "Please select a customer",
|
||||
"Please select a domain": "Please select a domain",
|
||||
"Please select a software product": "Please select a software product",
|
||||
"Selected check template not found": "Selected check template not found",
|
||||
"Security check could not be created": "Security check could not be created",
|
||||
"Security check created": "Security check created",
|
||||
"Security check not found": "Security check not found",
|
||||
"Security check saved": "Security check saved",
|
||||
"Saved. Fill in all required fields to complete the affected steps.": "Saved. Fill in all required fields to complete the affected steps.",
|
||||
"Fill in all required fields to complete this step.": "Fill in all required fields to complete this step.",
|
||||
"Security check deleted": "Security check deleted",
|
||||
"Security check archived": "Security check archived",
|
||||
"Security check reopened": "Security check reopened",
|
||||
"Archived checks cannot be edited": "Archived checks cannot be edited",
|
||||
"This security check cannot be edited": "This security check cannot be edited",
|
||||
"Could not save the security check": "Could not save the security check",
|
||||
"Could not update the status": "Could not update the status",
|
||||
"Product code cannot be empty": "Product code cannot be empty",
|
||||
"Product code is invalid": "Product code is invalid",
|
||||
"A template with this product code already exists": "A template with this product code already exists",
|
||||
"Product name cannot be empty": "Product name cannot be empty",
|
||||
"Template could not be created": "Template could not be created",
|
||||
"Template could not be updated": "Template could not be updated",
|
||||
"Template not found": "Template not found",
|
||||
"Template created": "Template created",
|
||||
"Template saved": "Template saved",
|
||||
"Maximum %d items allowed": "Maximum %d items allowed",
|
||||
"Item %d has an invalid type": "Item %d has an invalid type",
|
||||
"Item %d is missing a label": "Item %d is missing a label",
|
||||
"Item %d has an invalid key": "Item %d has an invalid key",
|
||||
"Could not encode the checklist": "Could not encode the checklist",
|
||||
"Checklist could not be saved": "Checklist could not be saved",
|
||||
"Generate PDF customer report": "Generate PDF customer report",
|
||||
"Opens in a new tab and reflects the last saved state.": "Opens in a new tab and reflects the last saved state.",
|
||||
"Complete and save all previous steps to enable the customer report.": "Complete and save all previous steps to enable the customer report.",
|
||||
"Complete all previous steps before generating the customer report.": "Complete all previous steps before generating the customer report.",
|
||||
"Security check report": "Security check report",
|
||||
"Generated on": "Generated on",
|
||||
"Result": "Result",
|
||||
"Process": "Process",
|
||||
"Not completed": "Not completed",
|
||||
"%d of %d checks completed": "%d of %d checks completed"
|
||||
}
|
||||
@@ -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, '-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Security module: per-product check templates (the curated product catalog).
|
||||
-- Each template carries the dynamic technical checklist for one software product.
|
||||
-- Tenant-scoped; BC is not involved (the template catalog IS the product list).
|
||||
CREATE TABLE IF NOT EXISTS `security_check_templates` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`product_code` VARCHAR(60) NOT NULL,
|
||||
`product_name` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`tech_schema_json` TEXT NULL DEFAULT NULL,
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sct_tenant_code` (`tenant_id`, `product_code`),
|
||||
KEY `idx_sct_tenant_active` (`tenant_id`, `active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
29
modules/security/migrations/002_create_security_checks.sql
Normal file
29
modules/security/migrations/002_create_security_checks.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Security module: a security check instance (the "project").
|
||||
-- Fixed 7-step process state + product-specific technical checklist state are
|
||||
-- stored as JSON. tech_schema_snapshot_json freezes the template's checklist at
|
||||
-- creation time so later template edits don't mutate in-flight checks.
|
||||
CREATE TABLE IF NOT EXISTS `security_checks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`debitor_no` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`debitor_name` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`domain_no` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`domain_url` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`product_code` VARCHAR(60) NOT NULL DEFAULT '',
|
||||
`product_name` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`template_id` BIGINT UNSIGNED NULL DEFAULT NULL,
|
||||
`owner_user_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
`process_state_json` TEXT NULL DEFAULT NULL,
|
||||
`tech_schema_snapshot_json` TEXT NULL DEFAULT NULL,
|
||||
`tech_state_json` TEXT NULL DEFAULT NULL,
|
||||
`created_by` INT UNSIGNED NOT NULL,
|
||||
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sc_tenant` (`tenant_id`),
|
||||
KEY `idx_sc_tenant_status` (`tenant_id`, `status`),
|
||||
KEY `idx_sc_tenant_debitor` (`tenant_id`, `debitor_no`),
|
||||
KEY `idx_sc_tenant_product` (`tenant_id`, `product_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Security module: OAuth2 token cache for the thin BC gateway
|
||||
-- (used only when the BC connection runs in OAuth2 auth mode).
|
||||
-- Tokens are stored encrypted and tenant-scoped.
|
||||
CREATE TABLE IF NOT EXISTS `security_oauth_token_cache` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`access_token_encrypted` TEXT NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_security_oauth_tenant` (`tenant_id`),
|
||||
INDEX `idx_security_oauth_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
162
modules/security/module.php
Normal file
162
modules/security/module.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security module manifest.
|
||||
*
|
||||
* Standalone module (no cross-module dependency). Lets the security officer
|
||||
* create and track customer security checks: a fixed internal process plus a
|
||||
* per-product technical checklist. Customer + domain selection use a thin,
|
||||
* self-contained Business Central OData gateway; the software-product catalog
|
||||
* is owned by this module (the check templates ARE the product list).
|
||||
*/
|
||||
return [
|
||||
'id' => 'security',
|
||||
'version' => '1.0.0',
|
||||
'enabled_by_default' => false,
|
||||
'load_order' => 30,
|
||||
'requires' => [],
|
||||
|
||||
'routes' => [
|
||||
['path' => 'security/checks', 'target' => 'security/checks'],
|
||||
['path' => 'security/checks-data', 'target' => 'security/checks-data'],
|
||||
['path' => 'security/checks/create', 'target' => 'security/checks/create'],
|
||||
['path' => 'security/checks/edit/{id}', 'target' => 'security/checks/edit'],
|
||||
['path' => 'security/checks/report/{id}', 'target' => 'security/checks/report'],
|
||||
['path' => 'security/customer-search-data', 'target' => 'security/customer-search-data'],
|
||||
['path' => 'security/customer-domains-data', 'target' => 'security/customer-domains-data'],
|
||||
['path' => 'security/templates', 'target' => 'security/templates'],
|
||||
['path' => 'security/templates-data', 'target' => 'security/templates-data'],
|
||||
['path' => 'security/templates/create', 'target' => 'security/templates/create'],
|
||||
['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'],
|
||||
['path' => 'security/settings', 'target' => 'security/settings'],
|
||||
['path' => 'security/settings/test-connection-data', 'target' => 'security/settings/test-connection-data'],
|
||||
],
|
||||
'public_paths' => [],
|
||||
|
||||
'container_registrars' => [
|
||||
\MintyPHP\Module\Security\SecurityContainerRegistrar::class,
|
||||
],
|
||||
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
[
|
||||
'key' => 'security',
|
||||
'label' => 'Security',
|
||||
'icon' => 'bi-shield-check',
|
||||
'icon_fill' => 'bi-shield-check',
|
||||
'href' => '',
|
||||
'permission' => 'security.access',
|
||||
'panel_template' => 'templates/aside-security-panel.phtml',
|
||||
'details_storage' => 'aside-security-sections-v1',
|
||||
'details_open_active' => true,
|
||||
'order' => 850,
|
||||
],
|
||||
],
|
||||
'runtime.component' => [
|
||||
[
|
||||
'key' => 'security-customer-domain-select',
|
||||
'script' => 'modules/security/js/security-customer-domain-select.js',
|
||||
'export' => 'initSecurityCustomerDomainSelect',
|
||||
'selector' => '#security-domain-group[data-app-component="security-customer-domain-select"]',
|
||||
'config_path' => 'components.security.customerDomainSelect',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'security.checks.create',
|
||||
'order' => 851,
|
||||
],
|
||||
[
|
||||
'key' => 'security-check-workspace',
|
||||
'script' => 'modules/security/js/security-check-workspace.js',
|
||||
'export' => 'initSecurityCheckWorkspace',
|
||||
'selector' => '[data-app-component="security-check-workspace"]',
|
||||
'config_path' => 'components.security.checkWorkspace',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'security.checks.view',
|
||||
'order' => 852,
|
||||
],
|
||||
[
|
||||
'key' => 'security-template-schema-editor',
|
||||
'script' => 'modules/security/js/security-template-schema-editor.js',
|
||||
'export' => 'initSecurityTemplateSchemaEditor',
|
||||
'selector' => '#security-schema-editor[data-app-component="security-template-schema-editor"]',
|
||||
'config_path' => 'components.security.templateSchemaEditor',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'security.templates.manage',
|
||||
'order' => 853,
|
||||
],
|
||||
[
|
||||
'key' => 'security-settings',
|
||||
'script' => 'modules/security/js/security-settings.js',
|
||||
'export' => 'initSecuritySettings',
|
||||
'selector' => '[data-app-component="security-settings"]',
|
||||
'config_path' => 'components.security.settings',
|
||||
'default_config' => [],
|
||||
'phase' => 'default',
|
||||
'permission' => 'security.settings.manage',
|
||||
'order' => 854,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'authorization_policies' => [
|
||||
\MintyPHP\Module\Security\SecurityAuthorizationPolicy::class,
|
||||
],
|
||||
|
||||
'permissions' => [
|
||||
[
|
||||
'key' => 'security.access',
|
||||
'description' => 'Access the Security module',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'security.checks.view',
|
||||
'description' => 'View security checks',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'security.checks.create',
|
||||
'description' => 'Create security checks and edit their checklist',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'security.checks.manage',
|
||||
'description' => 'Manage all security checks (archive, delete)',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'security.templates.manage',
|
||||
'description' => 'Manage security check templates and their technical checklists',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'security.settings.manage',
|
||||
'description' => 'Manage Security module Business Central connection settings',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'search_resources' => [],
|
||||
'asset_groups' => [
|
||||
'security' => [
|
||||
'modules/security/css/security.css',
|
||||
],
|
||||
],
|
||||
'scheduler_jobs' => [],
|
||||
|
||||
'layout_context_providers' => [
|
||||
\MintyPHP\Module\Security\Providers\SecurityLayoutProvider::class,
|
||||
],
|
||||
'session_providers' => [],
|
||||
'event_listeners' => [],
|
||||
'deactivation_handler' => null,
|
||||
'migrations_path' => 'migrations',
|
||||
'i18n_path' => 'i18n',
|
||||
];
|
||||
47
modules/security/pages/security/checks-data().php
Normal file
47
modules/security/pages/security/checks-data().php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/checks/filter-schema.php');
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
|
||||
if (!isset($filters['view']) || !in_array($filters['view'], ['active', 'done'], true)) {
|
||||
$filters['view'] = 'active';
|
||||
}
|
||||
|
||||
$result = app(SecurityCheckService::class)->listPaged($tenantId, $filters);
|
||||
$rows = $result['rows'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
|
||||
$editBaseUrl = lurl('security/checks/edit/');
|
||||
|
||||
$preparedRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$status = (string) ($row['status'] ?? 'open');
|
||||
|
||||
$preparedRows[] = [
|
||||
'id' => $id,
|
||||
'debitor_name' => (string) ($row['debitor_name'] ?? ''),
|
||||
'domain_url' => (string) ($row['domain_url'] ?? ''),
|
||||
'product_name' => (string) ($row['product_name'] ?? ($row['product_code'] ?? '')),
|
||||
'status' => $status,
|
||||
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
|
||||
'status_variant' => SecurityCheckProcess::statusVariant($status),
|
||||
'owner_name' => (string) ($row['owner_name'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($preparedRows, $total);
|
||||
78
modules/security/pages/security/checks/create().php
Normal file
78
modules/security/pages/security/checks/create().php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
|
||||
|
||||
$request = requestInput();
|
||||
$returnTarget = requestResolveReturnTarget('security/checks');
|
||||
$closeTarget = requestResolveReturnTarget('security/checks');
|
||||
$createTarget = requestPathWithReturnTarget('security/checks/create', $returnTarget);
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
|
||||
$templateService = app(SecurityCheckTemplateService::class);
|
||||
$checkService = app(SecurityCheckService::class);
|
||||
$errorBag = formErrors();
|
||||
|
||||
$form = [
|
||||
'debitor_no' => (string) $request->body('debitor_no', ''),
|
||||
'debitor_name' => (string) $request->body('debitor_name', ''),
|
||||
'domain_no' => (string) $request->body('domain_no', ''),
|
||||
'domain_url' => (string) $request->body('domain_url', ''),
|
||||
'product_code' => (string) $request->body('product_code', ''),
|
||||
'owner_user_id' => (int) $request->body('owner_user_id', (string) $userId),
|
||||
];
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if (!Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
|
||||
Router::redirect($createTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $checkService->create($tenantId, [
|
||||
'debitor_no' => $form['debitor_no'],
|
||||
'debitor_name' => $form['debitor_name'],
|
||||
'domain_no' => $form['domain_no'],
|
||||
'domain_url' => $form['domain_url'],
|
||||
'product_code' => $form['product_code'],
|
||||
'owner_user_id' => $form['owner_user_id'],
|
||||
], $userId);
|
||||
|
||||
if ($result['ok'] ?? false) {
|
||||
$editUrl = lurl('security/checks/edit/' . (int) $result['id']);
|
||||
Flash::success(t('Security check created'), $editUrl, 'security_check_created');
|
||||
Router::redirect($editUrl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
}
|
||||
|
||||
$templates = $templateService->listActive($tenantId);
|
||||
$assignableUsers = $checkService->listAssignableUsers($tenantId);
|
||||
$validationSummaryErrors = $errorBag->toArray();
|
||||
$errors = $errorBag->toFlatList();
|
||||
|
||||
Buffer::set('title', t('New security check'));
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Security checks'), 'path' => 'security/checks'],
|
||||
['label' => t('New security check')],
|
||||
];
|
||||
132
modules/security/pages/security/checks/create(default).phtml
Normal file
132
modules/security/pages/security/checks/create(default).phtml
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
$form = is_array($form ?? null) ? $form : [];
|
||||
$errors = is_array($errors ?? null) ? $errors : [];
|
||||
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
|
||||
$templates = is_array($templates ?? null) ? $templates : [];
|
||||
$assignableUsers = is_array($assignableUsers ?? null) ? $assignableUsers : [];
|
||||
$closeTarget = $closeTarget ?? 'security/checks';
|
||||
?>
|
||||
<div class="app-wizard-content">
|
||||
<div class="app-wizard-header">
|
||||
<a href="<?php e(lurl($closeTarget)); ?>" class="app-wizard-back" data-tooltip="<?php e(t('Back to list')); ?>">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h1 class="app-wizard-title"><?php e(t('New security check')); ?></h1>
|
||||
</div>
|
||||
|
||||
<?php if ($validationSummaryErrors): ?>
|
||||
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="app-wizard-card">
|
||||
<form method="post" id="security-check-create-form">
|
||||
<h2 class="app-wizard-card-heading"><?php e(t('Select customer, domain and product')); ?></h2>
|
||||
<p class="app-wizard-card-description"><?php e(t('Choose the customer, domain and software product this security check applies to.')); ?></p>
|
||||
|
||||
<div class="app-wizard-field-group">
|
||||
<label><?php e(t('Customer')); ?> <span class="security-form-required">*</span></label>
|
||||
<div
|
||||
data-app-lookup
|
||||
data-lookup-url="<?php e(lurl('security/customer-search-data')); ?>"
|
||||
data-lookup-min-chars="2"
|
||||
data-lookup-debounce="300"
|
||||
data-lookup-value-key="value"
|
||||
data-lookup-display-key="label"
|
||||
data-lookup-placeholder="<?php e(t('Search by name or number...')); ?>"
|
||||
data-lookup-empty-text="<?php e(t('No customers found')); ?>"
|
||||
data-lookup-error-text="<?php e(t('Search failed')); ?>"
|
||||
data-lookup-initial-value="<?php e($form['debitor_no'] ?? ''); ?>"
|
||||
data-lookup-initial-display="<?php e($form['debitor_name'] ?? ''); ?>"
|
||||
>
|
||||
<input type="hidden" name="debitor_no" data-lookup-value value="<?php e($form['debitor_no'] ?? ''); ?>">
|
||||
<input type="hidden" name="debitor_name" data-lookup-display-value value="<?php e($form['debitor_name'] ?? ''); ?>">
|
||||
</div>
|
||||
<?php if (!empty($errors['debitor_no'])): ?>
|
||||
<p class="field-error"><?php e($errors['debitor_no']); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="app-wizard-field-group" id="security-domain-group" data-app-component="security-customer-domain-select"
|
||||
data-domains-url="<?php e(lurl('security/customer-domains-data')); ?>"
|
||||
data-text-initial="<?php e(t('Select a customer first...')); ?>"
|
||||
data-text-loading="<?php e(t('Loading domains...')); ?>"
|
||||
data-text-select="<?php e(t('Please select...')); ?>"
|
||||
data-text-empty="<?php e(t('No domains found for this customer')); ?>"
|
||||
data-text-error="<?php e(t('Failed to load domains')); ?>"
|
||||
data-initial-debitor="<?php e($form['debitor_no'] ?? ''); ?>"
|
||||
data-initial-domain="<?php e($form['domain_no'] ?? ''); ?>"
|
||||
>
|
||||
<label for="security-domain"><?php e(t('Domain')); ?> <span class="security-form-required">*</span></label>
|
||||
<div class="app-wizard-domain-select-wrap">
|
||||
<select id="security-domain" disabled>
|
||||
<option value=""><?php e(t('Select a customer first...')); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="domain_no" id="security-domain-no" value="<?php e($form['domain_no'] ?? ''); ?>">
|
||||
<input type="hidden" name="domain_url" id="security-domain-url" value="<?php e($form['domain_url'] ?? ''); ?>">
|
||||
<div id="security-domain-feedback" class="app-wizard-field-feedback" role="alert" hidden></div>
|
||||
|
||||
<div id="security-domain-manual" hidden>
|
||||
<p class="app-wizard-field-hint"><?php e(t('No domains found for this customer. Enter domain name manually:')); ?></p>
|
||||
<input type="text" id="security-domain-manual-no" placeholder="<?php e(t('Domain name (e.g. example.de)')); ?>"
|
||||
value="<?php e($form['domain_no'] ?? ''); ?>" autocomplete="off">
|
||||
<input type="text" id="security-domain-manual-url" placeholder="<?php e(t('URL (optional, e.g. https://example.de)')); ?>"
|
||||
value="<?php e($form['domain_url'] ?? ''); ?>" autocomplete="off" style="margin-top:0.4rem;">
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors['domain_no'])): ?>
|
||||
<p class="field-error"><?php e($errors['domain_no']); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="app-wizard-field-group">
|
||||
<label for="security-product-code"><?php e(t('Software product')); ?> <span class="security-form-required">*</span></label>
|
||||
<?php if ($templates === []): ?>
|
||||
<div class="notice" data-variant="info" role="alert">
|
||||
<p><?php e(t('No check templates found. Create a check template first.')); ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<select id="security-product-code" name="product_code">
|
||||
<option value=""><?php e(t('Please select...')); ?></option>
|
||||
<?php foreach ($templates as $tpl):
|
||||
$code = (string) ($tpl['product_code'] ?? '');
|
||||
$name = trim((string) ($tpl['product_name'] ?? ''));
|
||||
$displayLabel = $name !== '' ? $name : $code;
|
||||
?>
|
||||
<option value="<?php e($code); ?>"<?php if (($form['product_code'] ?? '') === $code): ?> selected<?php endif; ?>>
|
||||
<?php e($displayLabel); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (!empty($errors['product_code'])): ?>
|
||||
<p class="field-error"><?php e($errors['product_code']); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="app-wizard-field-group">
|
||||
<label for="security-owner"><?php e(t('Owner')); ?></label>
|
||||
<select id="security-owner" name="owner_user_id">
|
||||
<?php
|
||||
$selectedOwner = (int) ($form['owner_user_id'] ?? 0);
|
||||
foreach ($assignableUsers as $assignableUser):
|
||||
$assignableUserId = (int) ($assignableUser['id'] ?? 0);
|
||||
if ($assignableUserId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$assignableUserName = trim((string) ($assignableUser['display_name'] ?? ''));
|
||||
?>
|
||||
<option value="<?php e((string) $assignableUserId); ?>"<?php if ($assignableUserId === $selectedOwner): ?> selected<?php endif; ?>><?php e($assignableUserName !== '' ? $assignableUserName : ('#' . $assignableUserId)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
|
||||
<div class="app-wizard-actions">
|
||||
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
|
||||
<button type="submit" class="primary"<?php if ($templates === []): ?> disabled<?php endif; ?>><?php e(t('Create security check')); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
166
modules/security/pages/security/checks/edit($id).php
Normal file
166
modules/security/pages/security/checks/edit($id).php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
|
||||
|
||||
$request = requestInput();
|
||||
$returnTarget = requestResolveReturnTarget();
|
||||
$closeTarget = requestResolveReturnTarget('security/checks');
|
||||
$checkId = (int) ($id ?? 0);
|
||||
$editBasePath = 'security/checks/edit/' . $checkId;
|
||||
$editTarget = requestPathWithReturnTarget($editBasePath, $returnTarget);
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
|
||||
$service = app(SecurityCheckService::class);
|
||||
$process = app(SecurityCheckProcess::class);
|
||||
$check = $service->findById($tenantId, $checkId);
|
||||
|
||||
if ($check === null) {
|
||||
Flash::error(t('Security check not found'), $closeTarget, 'security_check_not_found');
|
||||
Router::redirect($closeTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$authService = app(AuthorizationService::class);
|
||||
$actorContext = ['actor_user_id' => $userId];
|
||||
$canManage = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_MANAGE, $actorContext)->isAllowed();
|
||||
$canCreate = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE, $actorContext)->isAllowed();
|
||||
$canEdit = ($canManage || $canCreate) && (string) ($check['status'] ?? '') !== SecurityCheckProcess::STATUS_ARCHIVED;
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if (!Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$action = (string) $request->body('action', 'save');
|
||||
|
||||
if ($action === 'delete') {
|
||||
if (!$canManage) {
|
||||
Flash::error(t('Permission denied'), $editTarget, 'permission_denied');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
$service->delete($tenantId, $checkId);
|
||||
Flash::success(t('Security check deleted'), null, 'security_check_deleted');
|
||||
Router::redirect($closeTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'archive' || $action === 'unarchive') {
|
||||
if (!$canManage) {
|
||||
Flash::error(t('Permission denied'), $editTarget, 'permission_denied');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
$service->setArchived($tenantId, $checkId, $action === 'archive', $userId);
|
||||
Flash::success($action === 'archive' ? t('Security check archived') : t('Security check reopened'), $editTarget, 'security_check_status');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal checklist save
|
||||
if (!$canEdit) {
|
||||
Flash::error(t('This security check cannot be edited'), $editTarget, 'not_editable');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->saveState($tenantId, $checkId, [
|
||||
'step' => (array) $request->body('step', []),
|
||||
'tech' => (array) $request->body('tech', []),
|
||||
], $userId);
|
||||
|
||||
if ($result['ok'] ?? false) {
|
||||
$warning = (string) ($result['warning'] ?? '');
|
||||
if ($warning !== '') {
|
||||
Flash::add('warning', $warning, $editTarget, 'security_check_saved');
|
||||
} else {
|
||||
Flash::success(t('Security check saved'), $editTarget, 'security_check_saved');
|
||||
}
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstError = reset($result['errors']) ?: t('Could not save the security check');
|
||||
Flash::error($firstError, $editTarget, 'security_check_save_failed');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve user-id → display-name for the completion log ("who completed it").
|
||||
$userNames = [];
|
||||
foreach ($service->listAssignableUsers($tenantId) as $assignableUser) {
|
||||
$userNames[(int) $assignableUser['id']] = (string) $assignableUser['display_name'];
|
||||
}
|
||||
|
||||
$steps = $process->steps();
|
||||
$techItems = $check['tech_items'] ?? [];
|
||||
|
||||
// Pre-render each checkpoint's Markdown guidance to sanitized HTML for the workspace.
|
||||
$markdownGateway = app(SecurityMarkdownGateway::class);
|
||||
if (is_array($check['tech_schema_snapshot']['items'] ?? null)) {
|
||||
$check['tech_schema_snapshot']['items'] = array_map(
|
||||
static function ($item) use ($markdownGateway) {
|
||||
if (
|
||||
is_array($item)
|
||||
&& ($item['type'] ?? '') === 'check'
|
||||
&& trim((string) ($item['markdown'] ?? '')) !== ''
|
||||
) {
|
||||
$item['markdown_html'] = $markdownGateway->toHtml((string) $item['markdown']);
|
||||
}
|
||||
|
||||
return $item;
|
||||
},
|
||||
$check['tech_schema_snapshot']['items']
|
||||
);
|
||||
}
|
||||
$processState = $check['process_state'] ?? [];
|
||||
$techState = $check['tech_state'] ?? [];
|
||||
$progress = $check['progress'] ?? [];
|
||||
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
|
||||
|
||||
// Step 6 ("report") can produce a customer PDF only once every preceding step
|
||||
// is complete. Authoritative gate; the view mirrors it and the report action
|
||||
// re-checks it server-side.
|
||||
$canGenerateReport = $process->reportPrerequisitesMet(
|
||||
is_array($processState) ? $processState : [],
|
||||
is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [],
|
||||
is_array($techState) ? $techState : []
|
||||
);
|
||||
$reportPdfUrl = lurl('security/checks/report/' . $checkId);
|
||||
|
||||
$titleText = t('Security check') . ' — ' . (string) ($check['debitor_name'] ?? ($check['debitor_no'] ?? ''));
|
||||
Buffer::set('title', $titleText);
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Security checks'), 'path' => 'security/checks'],
|
||||
['label' => $titleText],
|
||||
];
|
||||
257
modules/security/pages/security/checks/edit(default).phtml
Normal file
257
modules/security/pages/security/checks/edit(default).phtml
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
|
||||
$check = is_array($check ?? null) ? $check : [];
|
||||
$steps = is_array($steps ?? null) ? $steps : [];
|
||||
$techItems = is_array($techItems ?? null) ? $techItems : [];
|
||||
$processState = is_array($processState ?? null) ? $processState : [];
|
||||
$techState = is_array($techState ?? null) ? $techState : [];
|
||||
$progress = is_array($progress ?? null) ? $progress : [];
|
||||
$userNames = is_array($userNames ?? null) ? $userNames : [];
|
||||
$canEdit = $canEdit ?? false;
|
||||
$canManage = $canManage ?? false;
|
||||
$canGenerateReport = $canGenerateReport ?? false;
|
||||
$reportPdfUrl = (string) ($reportPdfUrl ?? '');
|
||||
$status = (string) ($status ?? SecurityCheckProcess::STATUS_OPEN);
|
||||
$readonly = !$canEdit;
|
||||
|
||||
$percent = (int) ($progress['percent'] ?? 0);
|
||||
$doneCount = (int) ($progress['done'] ?? 0);
|
||||
$totalCount = (int) ($progress['total'] ?? 0);
|
||||
|
||||
$completionMeta = static function (array $entry) use ($userNames): string {
|
||||
if (empty($entry['done'])) {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
$by = (int) ($entry['by'] ?? 0);
|
||||
if ($by > 0 && isset($userNames[$by]) && $userNames[$by] !== '') {
|
||||
$parts[] = $userNames[$by];
|
||||
}
|
||||
$at = trim((string) ($entry['at'] ?? ''));
|
||||
if ($at !== '') {
|
||||
$parts[] = $at;
|
||||
}
|
||||
return $parts === [] ? '' : implode(' · ', $parts);
|
||||
};
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section data-tab-panel data-tab-panel-wide>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Security check'),
|
||||
'backHref' => lurl($closeTarget ?? 'security/checks'),
|
||||
];
|
||||
if (!$readonly) {
|
||||
$titlebar['actions'] = [
|
||||
[
|
||||
'label' => t('Save'),
|
||||
'type' => 'submit',
|
||||
'form' => 'security-check-form',
|
||||
'name' => 'action',
|
||||
'value' => 'save',
|
||||
'class' => 'primary',
|
||||
'tone' => 'success',
|
||||
],
|
||||
];
|
||||
}
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="security-check-summary">
|
||||
<dl class="security-check-meta">
|
||||
<div><dt><?php e(t('Customer')); ?></dt><dd><?php e((string) ($check['debitor_name'] ?? '')); ?> <span class="app-muted">(<?php e((string) ($check['debitor_no'] ?? '')); ?>)</span></dd></div>
|
||||
<div><dt><?php e(t('Domain')); ?></dt><dd><?php e((string) ($check['domain_url'] ?? ($check['domain_no'] ?? ''))); ?></dd></div>
|
||||
<div><dt><?php e(t('Software product')); ?></dt><dd><?php e((string) ($check['product_name'] ?? ($check['product_code'] ?? ''))); ?></dd></div>
|
||||
<div><dt><?php e(t('Owner')); ?></dt><dd><?php e((string) ($check['owner_name'] ?? '—')); ?></dd></div>
|
||||
<div><dt><?php e(t('Status')); ?></dt><dd><span class="badge" data-variant="<?php e(SecurityCheckProcess::statusVariant($status)); ?>"><?php e(t(SecurityCheckProcess::statusLabel($status))); ?></span></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="security-check-progress">
|
||||
<div class="security-check-progress-bar" role="progressbar" aria-label="<?php e(t('Progress')); ?>" aria-valuenow="<?php e((string) $percent); ?>" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="security-check-progress-fill" style="width: <?php e((string) $percent); ?>%"></div>
|
||||
</div>
|
||||
<span class="security-check-progress-label"><?php e(t('%d of %d completed', $doneCount, $totalCount)); ?> (<?php e((string) $percent); ?>%)</span>
|
||||
</div>
|
||||
|
||||
<?php if ($readonly && $status === SecurityCheckProcess::STATUS_ARCHIVED): ?>
|
||||
<div class="notice" data-variant="info" role="status">
|
||||
<p><?php e(t('This security check is archived and read-only.')); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Checklist -->
|
||||
<form method="post" id="security-check-form" data-app-component="security-check-workspace">
|
||||
<div class="security-steps">
|
||||
<?php foreach ($steps as $index => $step):
|
||||
$key = (string) ($step['key'] ?? '');
|
||||
$kind = (string) ($step['kind'] ?? SecurityCheckProcess::KIND_STEP);
|
||||
$stepNumber = $index + 1;
|
||||
?>
|
||||
<?php if ($kind === SecurityCheckProcess::KIND_TECH_CHECKLIST): ?>
|
||||
<!-- Step: technical checklist (per product) -->
|
||||
<article class="security-step security-step-tech">
|
||||
<header class="security-step-head">
|
||||
<span class="security-step-number"><?php e((string) $stepNumber); ?></span>
|
||||
<div>
|
||||
<h3><?php e(t((string) ($step['label'] ?? ''))); ?></h3>
|
||||
<p class="app-muted"><?php e(t((string) ($step['description'] ?? ''))); ?></p>
|
||||
</div>
|
||||
<?php if (!empty($progress['step6_done'])): ?>
|
||||
<span class="badge" data-variant="success"><?php e(t('Complete')); ?></span>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<?php if ($techItems === []): ?>
|
||||
<p class="app-muted security-step-empty"><?php e(t('This product has no technical checklist items defined yet.')); ?></p>
|
||||
<?php else: ?>
|
||||
<ul class="security-tech-list">
|
||||
<?php
|
||||
$snapshotItems = is_array($check['tech_schema_snapshot']['items'] ?? null) ? $check['tech_schema_snapshot']['items'] : [];
|
||||
foreach ($snapshotItems as $item):
|
||||
if (!is_array($item)) { continue; }
|
||||
$itemType = (string) ($item['type'] ?? 'check');
|
||||
if ($itemType === 'section'): ?>
|
||||
<li class="security-tech-section"><?php e((string) ($item['label'] ?? '')); ?></li>
|
||||
<?php else:
|
||||
$itemKey = (string) ($item['key'] ?? '');
|
||||
if ($itemKey === '') { continue; }
|
||||
$entry = is_array($techState[$itemKey] ?? null) ? $techState[$itemKey] : [];
|
||||
$done = !empty($entry['done']);
|
||||
$note = (string) ($entry['note'] ?? '');
|
||||
$meta = $completionMeta($entry);
|
||||
?>
|
||||
<li class="security-tech-item<?php if ($done): ?> is-done<?php endif; ?>">
|
||||
<label class="security-check-toggle">
|
||||
<input type="checkbox" name="tech[<?php e($itemKey); ?>][done]" value="1"<?php if ($done): ?> checked<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||
<span class="security-tech-label">
|
||||
<?php e((string) ($item['label'] ?? '')); ?>
|
||||
<?php if (trim((string) ($item['hint'] ?? '')) !== ''): ?>
|
||||
<small class="app-muted security-tech-hint"><?php e((string) $item['hint']); ?></small>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</label>
|
||||
<?php if ($meta !== ''): ?><span class="security-completion-meta"><i class="bi bi-check2-circle"></i> <?php e($meta); ?></span><?php endif; ?>
|
||||
<?php if (trim((string) ($item['markdown_html'] ?? '')) !== ''):
|
||||
$markdownHtml = (string) $item['markdown_html'];
|
||||
require __DIR__ . '/../../../templates/tech-markdown.phtml';
|
||||
endif; ?>
|
||||
<input type="text" class="security-item-note" name="tech[<?php e($itemKey); ?>][note]" value="<?php e($note); ?>" placeholder="<?php e(t('Note (optional)')); ?>"<?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php else:
|
||||
$entry = is_array($processState[$key] ?? null) ? $processState[$key] : [];
|
||||
$done = !empty($entry['done']);
|
||||
$note = (string) ($entry['note'] ?? '');
|
||||
$meta = $completionMeta($entry);
|
||||
?>
|
||||
<article class="security-step<?php if ($done): ?> is-done<?php endif; ?>">
|
||||
<header class="security-step-head">
|
||||
<span class="security-step-number"><?php e((string) $stepNumber); ?></span>
|
||||
<label class="security-check-toggle security-step-toggle">
|
||||
<input type="checkbox" name="step[<?php e($key); ?>][done]" value="1"<?php if ($done): ?> checked<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||
<span>
|
||||
<strong><?php e(t((string) ($step['label'] ?? ''))); ?></strong>
|
||||
<small class="app-muted"><?php e(t((string) ($step['description'] ?? ''))); ?></small>
|
||||
</span>
|
||||
</label>
|
||||
<?php if ($meta !== ''): ?><span class="security-completion-meta"><i class="bi bi-check2-circle"></i> <?php e($meta); ?></span><?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php
|
||||
$stepFields = is_array($step['fields'] ?? null) ? $step['fields'] : [];
|
||||
if ($stepFields !== []):
|
||||
$fieldValues = is_array($entry['fields'] ?? null) ? $entry['fields'] : [];
|
||||
$hasRequired = false;
|
||||
?>
|
||||
<div class="security-step-fields">
|
||||
<?php foreach ($stepFields as $field):
|
||||
$fkey = (string) ($field['key'] ?? '');
|
||||
if ($fkey === '') { continue; }
|
||||
$ftype = (string) ($field['type'] ?? SecurityCheckProcess::FIELD_TEXTAREA);
|
||||
$isDatetime = $ftype === SecurityCheckProcess::FIELD_DATETIME;
|
||||
$fieldRequired = ($field['required'] ?? false) === true;
|
||||
if ($fieldRequired) { $hasRequired = true; }
|
||||
$fval = (string) ($fieldValues[$fkey] ?? '');
|
||||
$fname = 'step[' . $key . '][fields][' . $fkey . ']';
|
||||
$fid = 'security-field-' . $key . '-' . $fkey;
|
||||
?>
|
||||
<div class="security-step-field<?php if ($isDatetime): ?> security-step-field-datetime<?php endif; ?>">
|
||||
<label for="<?php e($fid); ?>"><?php e(t((string) ($field['label'] ?? ''))); ?><?php if ($fieldRequired): ?> <span class="security-form-required" aria-hidden="true">*</span><?php endif; ?></label>
|
||||
<?php if ($isDatetime): ?>
|
||||
<input type="datetime-local" id="<?php e($fid); ?>" name="<?php e($fname); ?>" value="<?php e($fval); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||
<?php else: ?>
|
||||
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="2"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if ($hasRequired): ?>
|
||||
<p class="security-step-gate-hint" data-security-gate-hint hidden>
|
||||
<i class="bi bi-info-circle"></i> <?php e(t('Fill in all required fields to complete this step.')); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="text" class="security-item-note" name="step[<?php e($key); ?>][note]" value="<?php e($note); ?>" placeholder="<?php e(t('Note (optional)')); ?>"<?php if ($readonly): ?> disabled<?php endif; ?>>
|
||||
|
||||
<?php if ($key === SecurityCheckProcess::STEP_REPORT): ?>
|
||||
<div class="security-report-action">
|
||||
<?php if ($canGenerateReport && $reportPdfUrl !== ''): ?>
|
||||
<a href="<?php e($reportPdfUrl); ?>" class="secondary outline small" target="_blank" rel="noopener">
|
||||
<i class="bi bi-file-earmark-pdf" aria-hidden="true"></i> <?php e(t('Generate PDF customer report')); ?>
|
||||
</a>
|
||||
<p class="app-muted security-report-hint"><?php e(t('Opens in a new tab and reflects the last saved state.')); ?></p>
|
||||
<?php else: ?>
|
||||
<button type="button" class="secondary outline small" disabled>
|
||||
<i class="bi bi-file-earmark-pdf" aria-hidden="true"></i> <?php e(t('Generate PDF customer report')); ?>
|
||||
</button>
|
||||
<p class="app-muted security-report-hint"><i class="bi bi-lock" aria-hidden="true"></i> <?php e(t('Complete and save all previous steps to enable the customer report.')); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!$readonly): ?>
|
||||
<div class="security-form-actions">
|
||||
<button type="submit" name="action" value="save" class="primary app-action-success"><?php e(t('Save')); ?></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
|
||||
<?php if ($canManage): ?>
|
||||
<!-- Danger zone -->
|
||||
<div class="security-danger-zone">
|
||||
<div class="security-danger-zone-inner">
|
||||
<h3><?php e(t('Danger zone')); ?></h3>
|
||||
<div class="security-danger-actions">
|
||||
<form method="post">
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
<?php if ($status === SecurityCheckProcess::STATUS_ARCHIVED): ?>
|
||||
<button type="submit" name="action" value="unarchive" class="secondary outline small"><i class="bi bi-arrow-counterclockwise"></i> <?php e(t('Reopen check')); ?></button>
|
||||
<?php else: ?>
|
||||
<button type="submit" name="action" value="archive" class="secondary outline small"><i class="bi bi-archive"></i> <?php e(t('Archive check')); ?></button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<form method="post"
|
||||
data-detail-confirm-message="<?php e(t('Delete this security check? This cannot be undone.')); ?>"
|
||||
data-detail-confirm-variant="danger">
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
<button type="submit" name="action" value="delete" class="danger outline small"><i class="bi bi-trash3"></i> <?php e(t('Delete check')); ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
48
modules/security/pages/security/checks/filter-schema.php
Normal file
48
modules/security/pages/security/checks/filter-schema.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'search' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string', 'default' => 'all'],
|
||||
'view' => ['type' => 'string', 'default' => 'active'],
|
||||
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'order' => [
|
||||
'type' => 'order',
|
||||
'allowed' => ['id', 'debitor_name', 'domain_url', 'product_name', 'status', 'created_at', 'updated_at'],
|
||||
'default' => 'created_at',
|
||||
],
|
||||
'dir' => ['type' => 'dir', 'default' => 'desc'],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'placeholder' => 'Search checks...',
|
||||
'input_id' => 'security-checks-search-input',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'view',
|
||||
'type' => 'hidden',
|
||||
],
|
||||
[
|
||||
'key' => 'status',
|
||||
'type' => 'select',
|
||||
'label' => 'Status',
|
||||
'input_id' => 'security-checks-status-filter',
|
||||
'default' => 'all',
|
||||
'normalize' => 'all_to_empty',
|
||||
'allowed' => [
|
||||
['id' => 'all', 'description' => 'All'],
|
||||
['id' => 'open', 'description' => 'Open'],
|
||||
['id' => 'in_progress', 'description' => 'In progress'],
|
||||
['id' => 'completed', 'description' => 'Completed'],
|
||||
['id' => 'archived', 'description' => 'Archived'],
|
||||
],
|
||||
'label_attributes' => ['data-filter-optional' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
53
modules/security/pages/security/checks/index().php
Normal file
53
modules/security/pages/security/checks/index().php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
|
||||
|
||||
$request = requestInput();
|
||||
$query = $request->queryAll();
|
||||
|
||||
$filterSchema = require __DIR__ . '/filter-schema.php';
|
||||
$listFilterContext = gridBuildListFilterContext($filterSchema, [
|
||||
'query' => $query,
|
||||
'search_keys' => ['search'],
|
||||
]);
|
||||
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
|
||||
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
|
||||
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
|
||||
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
|
||||
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
|
||||
$searchConfig = $listFilterContext['searchConfig'];
|
||||
$schemaByKey = $listFilterContext['schemaByKey'];
|
||||
$filterChipMeta = [
|
||||
'search' => [
|
||||
'label' => t('Search'),
|
||||
'type' => 'text',
|
||||
],
|
||||
'status' => [
|
||||
'label' => t('Status'),
|
||||
'type' => 'select',
|
||||
'default' => (string) ($schemaByKey['status']['default'] ?? 'all'),
|
||||
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
|
||||
],
|
||||
];
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$authService = app(AuthorizationService::class);
|
||||
$canCreate = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE, ['actor_user_id' => $userId])->isAllowed();
|
||||
|
||||
$activeView = in_array($query['view'] ?? '', ['active', 'done'], true) ? $query['view'] : 'active';
|
||||
|
||||
Buffer::set('title', t('Security checks'));
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Security checks')],
|
||||
];
|
||||
65
modules/security/pages/security/checks/index(default).phtml
Normal file
65
modules/security/pages/security/checks/index(default).phtml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
|
||||
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
|
||||
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
|
||||
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
|
||||
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
|
||||
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
|
||||
$canCreate = $canCreate ?? false;
|
||||
$activeView = $activeView ?? 'active';
|
||||
?>
|
||||
<?php
|
||||
$listTitle = t('Security checks');
|
||||
ob_start();
|
||||
?>
|
||||
<?php if ($canCreate): ?>
|
||||
<a role="button" class="app-action-success" href="<?php e(requestPathWithReturnTarget('security/checks/create', \MintyPHP\Http\Request::pathWithQuery())); ?>">
|
||||
<i class="bi bi-plus"></i> <?php e(t('New security check')); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$listTitleActionsHtml = ob_get_clean();
|
||||
require templatePath('partials/app-list-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<div class="app-tabs-nav" style="margin-bottom: 12px;">
|
||||
<a
|
||||
href="<?php e(lurl('security/checks') . '?view=active'); ?>"
|
||||
role="button"
|
||||
class="<?php e($activeView === 'active' ? 'primary small' : 'secondary outline small'); ?>"
|
||||
><?php e(t('In progress')); ?></a>
|
||||
<a
|
||||
href="<?php e(lurl('security/checks') . '?view=done'); ?>"
|
||||
role="button"
|
||||
class="<?php e($activeView === 'done' ? 'primary small' : 'secondary outline small'); ?>"
|
||||
><?php e(t('Done')); ?></a>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$filterUiNamespace = 'security-checks';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div class="app-list-table">
|
||||
<div id="security-checks-grid"></div>
|
||||
</div>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="application/json" id="page-config-security-checks"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('security/checks-data') . '?view=' . rawurlencode($activeView),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'editBaseUrl' => lurl('security/checks/edit/'),
|
||||
'activeView' => $activeView,
|
||||
'labels' => [
|
||||
'customer' => t('Customer'),
|
||||
'domain' => t('Domain'),
|
||||
'product' => t('Software product'),
|
||||
'status' => t('Status'),
|
||||
'owner' => t('Owner'),
|
||||
'createdAt' => t('Created'),
|
||||
],
|
||||
]); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/security/js/pages/security-checks-index.js')); ?>"></script>
|
||||
82
modules/security/pages/security/checks/report($id).php
Normal file
82
modules/security/pages/security/checks/report($id).php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
// Streams the generated PDF directly — no view template for this route.
|
||||
define('MINTY_ALLOW_OUTPUT', true);
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
|
||||
|
||||
$checkId = (int) ($id ?? 0);
|
||||
$closeTarget = requestResolveReturnTarget('security/checks');
|
||||
$editTarget = requestPathWithReturnTarget('security/checks/edit/' . $checkId, requestResolveReturnTarget());
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
|
||||
$service = app(SecurityCheckService::class);
|
||||
$process = app(SecurityCheckProcess::class);
|
||||
$check = $service->findById($tenantId, $checkId);
|
||||
|
||||
if ($check === null) {
|
||||
Flash::error(t('Security check not found'), $closeTarget, 'security_check_not_found');
|
||||
Router::redirect($closeTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-enforce the gate server-side: the report can only be produced once the
|
||||
// check has actually been carried out (all preceding steps complete). The UI
|
||||
// hides the button, but it must not be the only line of defence.
|
||||
$prerequisitesMet = $process->reportPrerequisitesMet(
|
||||
is_array($check['process_state'] ?? null) ? $check['process_state'] : [],
|
||||
is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [],
|
||||
is_array($check['tech_state'] ?? null) ? $check['tech_state'] : []
|
||||
);
|
||||
if (!$prerequisitesMet) {
|
||||
Flash::error(t('Complete all previous steps before generating the customer report.'), $editTarget, 'security_report_gated');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$pdfService = app(SecurityReportPdfService::class);
|
||||
$pdf = $pdfService->renderCustomerReportPdf($check, [
|
||||
'locale' => strtolower((string) (I18n::$locale ?? I18n::$defaultLocale)),
|
||||
'app_name' => appTitle(),
|
||||
'tenant_uuid' => (string) ($session['current_tenant']['uuid'] ?? ''),
|
||||
]);
|
||||
|
||||
if ($pdf === '') {
|
||||
// Concrete renderer/template failures are handled inside the service.
|
||||
http_response_code(500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = $pdfService->buildReportFilename($check);
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
||||
header('Cache-Control: private, no-store, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Length: ' . strlen($pdf));
|
||||
|
||||
$output = fopen('php://output', 'wb');
|
||||
if ($output === false) {
|
||||
http_response_code(500);
|
||||
|
||||
return;
|
||||
}
|
||||
fwrite($output, $pdf);
|
||||
fclose($output);
|
||||
47
modules/security/pages/security/customer-domains-data().php
Normal file
47
modules/security/pages/security/customer-domains-data().php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$request = requestInput();
|
||||
$debitorNo = trim((string) $request->query('debitor_no', ''));
|
||||
|
||||
if ($debitorNo === '') {
|
||||
Router::json([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = app(SecurityBcGateway::class)->listDomainsForCustomer($debitorNo);
|
||||
} catch (\Throwable) {
|
||||
http_response_code(502);
|
||||
Router::json(['error' => 'lookup_failed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$no = trim((string) ($row['No'] ?? ''));
|
||||
$url = trim((string) ($row['URL'] ?? ''));
|
||||
if ($no === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'value' => $no,
|
||||
'label' => $url !== '' ? $url : $no,
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
|
||||
Router::json($items);
|
||||
48
modules/security/pages/security/customer-search-data().php
Normal file
48
modules/security/pages/security/customer-search-data().php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$request = requestInput();
|
||||
$query = trim((string) $request->query('q', ''));
|
||||
|
||||
if ($query === '' || mb_strlen($query) < 2) {
|
||||
Router::json([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = app(SecurityBcGateway::class)->searchCustomers($query);
|
||||
} catch (\Throwable) {
|
||||
http_response_code(502);
|
||||
Router::json(['error' => 'search_failed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$no = trim((string) ($row['No'] ?? ''));
|
||||
$name = trim((string) ($row['Name'] ?? ''));
|
||||
if ($no === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'value' => $no,
|
||||
'label' => $no . ' — ' . $name,
|
||||
'name' => $name,
|
||||
'meta' => trim((string) ($row['City'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
Router::json($items);
|
||||
89
modules/security/pages/security/settings/index().php
Normal file
89
modules/security/pages/security/settings/index().php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$settingsGateway = app(SecurityBcSettingsGateway::class);
|
||||
$tokenRepository = app(SecurityTokenRepository::class);
|
||||
|
||||
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), 'security/settings', 'csrf_expired');
|
||||
Router::redirect('security/settings');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
$authMode = trim((string) ($post['auth_mode'] ?? SecurityBcSettingsGateway::AUTH_MODE_BASIC));
|
||||
$odataBaseUrl = trim((string) ($post['odata_base_url'] ?? ''));
|
||||
$companyName = trim((string) ($post['company_name'] ?? ''));
|
||||
$basicUser = trim((string) ($post['basic_user'] ?? ''));
|
||||
$basicPassword = trim((string) ($post['basic_password'] ?? ''));
|
||||
$oauthTenantId = trim((string) ($post['oauth_tenant_id'] ?? ''));
|
||||
$oauthClientId = trim((string) ($post['oauth_client_id'] ?? ''));
|
||||
$oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? ''));
|
||||
$oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? ''));
|
||||
|
||||
$errorBag = formErrors();
|
||||
if ($odataBaseUrl !== '' && !preg_match('#^https://#i', $odataBaseUrl)) {
|
||||
$errorBag->add('odata_base_url', t('OData Base URL must use HTTPS'));
|
||||
}
|
||||
if ($oauthTokenEndpoint !== '' && !preg_match('#^https://#i', $oauthTokenEndpoint)) {
|
||||
$errorBag->add('oauth_token_endpoint', t('Token endpoint must use HTTPS'));
|
||||
}
|
||||
|
||||
if (!$errorBag->hasAny()) {
|
||||
$settingsGateway->setAuthMode($authMode);
|
||||
$settingsGateway->setODataBaseUrl($odataBaseUrl !== '' ? $odataBaseUrl : null);
|
||||
$settingsGateway->setCompanyName($companyName !== '' ? $companyName : null);
|
||||
$settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null);
|
||||
if ($basicPassword !== '') {
|
||||
$settingsGateway->setBasicPassword($basicPassword);
|
||||
}
|
||||
$settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null);
|
||||
$settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null);
|
||||
if ($oauthClientSecret !== '') {
|
||||
$settingsGateway->setOAuthClientSecret($oauthClientSecret);
|
||||
}
|
||||
$settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint !== '' ? $oauthTokenEndpoint : null);
|
||||
|
||||
$tokenRepository->deleteAll();
|
||||
Flash::success(t('Settings saved'), 'security/settings', 'settings_saved');
|
||||
} else {
|
||||
flashFormErrors($errorBag, 'security/settings', 'settings_error');
|
||||
}
|
||||
|
||||
Router::redirect('security/settings');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$authMode = $settingsGateway->getAuthMode();
|
||||
$odataBaseUrl = $settingsGateway->getODataBaseUrl();
|
||||
$companyName = $settingsGateway->getCompanyName();
|
||||
$basicUser = $settingsGateway->getBasicUser() ?? '';
|
||||
$hasBasicPassword = $settingsGateway->getBasicPassword() !== null;
|
||||
$oauthTenantId = $settingsGateway->getOAuthTenantId() ?? '';
|
||||
$oauthClientId = $settingsGateway->getOAuthClientId() ?? '';
|
||||
$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null;
|
||||
$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? '';
|
||||
$configErrors = $settingsGateway->validateConfiguration();
|
||||
|
||||
Buffer::set('title', t('Security settings'));
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Settings')],
|
||||
];
|
||||
112
modules/security/pages/security/settings/index(default).phtml
Normal file
112
modules/security/pages/security/settings/index(default).phtml
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
|
||||
$authMode = (string) ($authMode ?? SecurityBcSettingsGateway::AUTH_MODE_BASIC);
|
||||
$odataBaseUrl = (string) ($odataBaseUrl ?? '');
|
||||
$companyName = (string) ($companyName ?? '');
|
||||
$basicUser = (string) ($basicUser ?? '');
|
||||
$hasBasicPassword = (bool) ($hasBasicPassword ?? false);
|
||||
$oauthTenantId = (string) ($oauthTenantId ?? '');
|
||||
$oauthClientId = (string) ($oauthClientId ?? '');
|
||||
$hasOAuthClientSecret = (bool) ($hasOAuthClientSecret ?? false);
|
||||
$oauthTokenEndpoint = (string) ($oauthTokenEndpoint ?? '');
|
||||
$configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
$isOauth = $authMode === SecurityBcSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section data-tab-panel>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Security settings'),
|
||||
'actions' => [
|
||||
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-settings-form', 'class' => 'primary', 'tone' => 'success'],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php if ($configErrors !== []): ?>
|
||||
<div class="notice" data-variant="warning" role="status">
|
||||
<p><strong><?php e(t('Connection not fully configured')); ?></strong></p>
|
||||
<ul>
|
||||
<?php foreach ($configErrors as $missing): ?><li><?php e((string) $missing); ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="security-settings-form" data-app-component="security-settings">
|
||||
<div class="app-details-card">
|
||||
<h3><?php e(t('Business Central connection')); ?></h3>
|
||||
|
||||
<div class="security-field">
|
||||
<label for="security-auth-mode"><?php e(t('Authentication mode')); ?></label>
|
||||
<select id="security-auth-mode" name="auth_mode" data-security-auth-mode>
|
||||
<option value="basic"<?php if (!$isOauth): ?> selected<?php endif; ?>><?php e(t('Basic authentication')); ?></option>
|
||||
<option value="oauth2"<?php if ($isOauth): ?> selected<?php endif; ?>><?php e(t('OAuth2 (client credentials)')); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="security-field">
|
||||
<label for="security-odata-url"><?php e(t('OData base URL')); ?></label>
|
||||
<input type="url" id="security-odata-url" name="odata_base_url" value="<?php e($odataBaseUrl); ?>" placeholder="https://bc.example.com:7048/BC/ODataV4">
|
||||
</div>
|
||||
|
||||
<div class="security-field">
|
||||
<label for="security-company"><?php e(t('Company name')); ?></label>
|
||||
<input type="text" id="security-company" name="company_name" value="<?php e($companyName); ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-details-card" data-security-auth-section="basic"<?php if ($isOauth): ?> hidden<?php endif; ?>>
|
||||
<h3><?php e(t('Basic authentication')); ?></h3>
|
||||
<div class="security-field">
|
||||
<label for="security-basic-user"><?php e(t('Username')); ?></label>
|
||||
<input type="text" id="security-basic-user" name="basic_user" value="<?php e($basicUser); ?>" autocomplete="off">
|
||||
</div>
|
||||
<div class="security-field">
|
||||
<label for="security-basic-pass"><?php e(t('Password')); ?></label>
|
||||
<input type="password" id="security-basic-pass" name="basic_password" value="" autocomplete="new-password"
|
||||
placeholder="<?php e($hasBasicPassword ? t('•••••••• (leave blank to keep)') : ''); ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-details-card" data-security-auth-section="oauth2"<?php if (!$isOauth): ?> hidden<?php endif; ?>>
|
||||
<h3><?php e(t('OAuth2 (client credentials)')); ?></h3>
|
||||
<div class="security-field">
|
||||
<label for="security-oauth-token-endpoint"><?php e(t('Token endpoint')); ?></label>
|
||||
<input type="url" id="security-oauth-token-endpoint" name="oauth_token_endpoint" value="<?php e($oauthTokenEndpoint); ?>"
|
||||
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
|
||||
</div>
|
||||
<div class="security-field">
|
||||
<label for="security-oauth-tenant"><?php e(t('Directory (tenant) ID')); ?></label>
|
||||
<input type="text" id="security-oauth-tenant" name="oauth_tenant_id" value="<?php e($oauthTenantId); ?>" autocomplete="off">
|
||||
</div>
|
||||
<div class="security-field">
|
||||
<label for="security-oauth-client"><?php e(t('Client ID')); ?></label>
|
||||
<input type="text" id="security-oauth-client" name="oauth_client_id" value="<?php e($oauthClientId); ?>" autocomplete="off">
|
||||
</div>
|
||||
<div class="security-field">
|
||||
<label for="security-oauth-secret"><?php e(t('Client secret')); ?></label>
|
||||
<input type="password" id="security-oauth-secret" name="oauth_client_secret" value="" autocomplete="new-password"
|
||||
placeholder="<?php e($hasOAuthClientSecret ? t('•••••••• (leave blank to keep)') : ''); ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
|
||||
<div class="app-details-card">
|
||||
<h3><?php e(t('Test connection')); ?></h3>
|
||||
<p class="app-muted"><?php e(t('Save your settings first, then test the connection to Business Central.')); ?></p>
|
||||
<button type="button" class="secondary outline" data-security-test-connection
|
||||
data-url="<?php e(lurl('security/settings/test-connection-data')); ?>"
|
||||
data-text-testing="<?php e(t('Testing...')); ?>"
|
||||
data-text-ok="<?php e(t('Connection successful')); ?>"
|
||||
data-text-fail="<?php e(t('Connection failed')); ?>">
|
||||
<i class="bi bi-plug"></i> <?php e(t('Test connection')); ?>
|
||||
</button>
|
||||
<span data-security-test-result role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$result = app(SecurityBcGateway::class)->testConnection();
|
||||
|
||||
if (($result['ok'] ?? false) === true) {
|
||||
Router::json(['ok' => true, 'http_code' => (int) ($result['http_code'] ?? 200)]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'error' => (string) ($result['error'] ?? 'Connection failed'),
|
||||
]);
|
||||
41
modules/security/pages/security/templates-data().php
Normal file
41
modules/security/pages/security/templates-data().php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/templates/filter-schema.php');
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
|
||||
$result = app(SecurityCheckTemplateService::class)->listPaged($tenantId, $filters);
|
||||
$rows = $result['rows'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
|
||||
$editBaseUrl = lurl('security/templates/edit/');
|
||||
|
||||
$preparedRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$items = SecurityCheckProcess::techCheckItems(
|
||||
is_array(json_decode((string) ($row['tech_schema_json'] ?? ''), true)) ? json_decode((string) ($row['tech_schema_json'] ?? ''), true) : []
|
||||
);
|
||||
$preparedRows[] = [
|
||||
'id' => $id,
|
||||
'product_code' => (string) ($row['product_code'] ?? ''),
|
||||
'product_name' => (string) ($row['product_name'] ?? ''),
|
||||
'item_count' => count($items),
|
||||
'active' => (int) ($row['active'] ?? 0) === 1,
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ($row['created_at'] ?? '')),
|
||||
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($preparedRows, $total);
|
||||
62
modules/security/pages/security/templates/create().php
Normal file
62
modules/security/pages/security/templates/create().php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$returnTarget = requestResolveReturnTarget('security/templates');
|
||||
$closeTarget = requestResolveReturnTarget('security/templates');
|
||||
$createTarget = requestPathWithReturnTarget('security/templates/create', $returnTarget);
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
|
||||
$service = app(SecurityCheckTemplateService::class);
|
||||
$errorBag = formErrors();
|
||||
|
||||
$form = [
|
||||
'product_code' => (string) $request->body('product_code', ''),
|
||||
'product_name' => (string) $request->body('product_name', ''),
|
||||
];
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if (!Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
|
||||
Router::redirect($createTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $service->create($tenantId, $form['product_code'], $form['product_name'], $userId);
|
||||
if ($result['ok'] ?? false) {
|
||||
$editUrl = lurl('security/templates/edit/' . (int) $result['id']);
|
||||
Flash::success(t('Template created'), $editUrl, 'template_created');
|
||||
Router::redirect($editUrl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$errorBag->merge($result['errors'] ?? []);
|
||||
}
|
||||
|
||||
$validationSummaryErrors = $errorBag->toArray();
|
||||
$errors = $errorBag->toFlatList();
|
||||
|
||||
Buffer::set('title', t('New template'));
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Check templates'), 'path' => 'security/templates'],
|
||||
['label' => t('New template')],
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
$form = is_array($form ?? null) ? $form : [];
|
||||
$errors = is_array($errors ?? null) ? $errors : [];
|
||||
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
|
||||
$closeTarget = $closeTarget ?? 'security/templates';
|
||||
?>
|
||||
<div class="app-wizard-content">
|
||||
<div class="app-wizard-header">
|
||||
<a href="<?php e(lurl($closeTarget)); ?>" class="app-wizard-back" data-tooltip="<?php e(t('Back to list')); ?>">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h1 class="app-wizard-title"><?php e(t('New check template')); ?></h1>
|
||||
</div>
|
||||
|
||||
<?php if ($validationSummaryErrors): ?>
|
||||
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="app-wizard-card">
|
||||
<form method="post" id="security-template-create-form">
|
||||
<h2 class="app-wizard-card-heading"><?php e(t('Create a check template')); ?></h2>
|
||||
<p class="app-wizard-card-description"><?php e(t('A template represents one software product and its technical checklist. You can add checklist items after creating it.')); ?></p>
|
||||
|
||||
<div class="app-wizard-field-group">
|
||||
<label for="security-template-code"><?php e(t('Product code')); ?> <span class="security-form-required">*</span></label>
|
||||
<input type="text" id="security-template-code" name="product_code" value="<?php e($form['product_code'] ?? ''); ?>"
|
||||
placeholder="<?php e(t('e.g. intranet, mysyde-cms')); ?>" autocomplete="off">
|
||||
<?php if (!empty($errors['product_code'])): ?>
|
||||
<p class="field-error"><?php e($errors['product_code']); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="app-wizard-field-group">
|
||||
<label for="security-template-name"><?php e(t('Product name')); ?> <span class="security-form-required">*</span></label>
|
||||
<input type="text" id="security-template-name" name="product_name" value="<?php e($form['product_name'] ?? ''); ?>"
|
||||
placeholder="<?php e(t('e.g. Intranet, Mysyde CMS')); ?>" autocomplete="off">
|
||||
<?php if (!empty($errors['product_name'])): ?>
|
||||
<p class="field-error"><?php e($errors['product_name']); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
|
||||
<div class="app-wizard-actions">
|
||||
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
|
||||
<button type="submit" class="primary"><?php e(t('Create template')); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
93
modules/security/pages/security/templates/edit($id).php
Normal file
93
modules/security/pages/security/templates/edit($id).php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$returnTarget = requestResolveReturnTarget();
|
||||
$closeTarget = requestResolveReturnTarget('security/templates');
|
||||
$templateId = (int) ($id ?? 0);
|
||||
$editTarget = requestPathWithReturnTarget('security/templates/edit/' . $templateId, $returnTarget);
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
|
||||
$service = app(SecurityCheckTemplateService::class);
|
||||
$template = $service->findById($tenantId, $templateId);
|
||||
|
||||
if ($template === null) {
|
||||
Flash::error(t('Template not found'), $closeTarget, 'template_not_found');
|
||||
Router::redirect($closeTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$errorBag = formErrors();
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
if (!Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = (string) $request->body('product_name', '');
|
||||
$active = $request->body('active', '') !== '';
|
||||
|
||||
$metaResult = $service->updateMeta($tenantId, $templateId, $name, $active, $userId);
|
||||
$errorBag->merge($metaResult['errors'] ?? []);
|
||||
|
||||
$items = [];
|
||||
$schemaJson = (string) $request->body('tech_schema_json', '');
|
||||
if ($schemaJson !== '') {
|
||||
$decoded = json_decode($schemaJson, true);
|
||||
if (is_array($decoded)) {
|
||||
$items = $decoded;
|
||||
}
|
||||
}
|
||||
$schemaResult = $service->saveTechSchema($tenantId, $templateId, $items, $userId);
|
||||
$errorBag->merge($schemaResult['errors'] ?? []);
|
||||
|
||||
if (!$errorBag->hasAny()) {
|
||||
$action = (string) $request->body('action', 'save');
|
||||
$target = $action === 'save_close' ? $closeTarget : $editTarget;
|
||||
Flash::success(t('Template saved'), $target, 'template_saved');
|
||||
Router::redirect($target);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
flashFormErrors($errorBag, $editTarget, 'template_save_failed');
|
||||
Router::redirect($editTarget);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = $service->decodeSchema($template);
|
||||
$schemaItems = $schema['items'] ?? [];
|
||||
|
||||
$validationSummaryErrors = $errorBag->toArray();
|
||||
$errors = $errorBag->toFlatList();
|
||||
|
||||
$titleText = trim((string) ($template['product_name'] ?? '')) !== ''
|
||||
? (string) $template['product_name']
|
||||
: t('Edit template');
|
||||
Buffer::set('title', $titleText);
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Check templates'), 'path' => 'security/templates'],
|
||||
['label' => $titleText],
|
||||
];
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
$template = is_array($template ?? null) ? $template : [];
|
||||
$schemaItems = is_array($schemaItems ?? null) ? $schemaItems : [];
|
||||
$errors = is_array($errors ?? null) ? $errors : [];
|
||||
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
|
||||
$closeTarget = $closeTarget ?? 'security/templates';
|
||||
$isActive = (int) ($template['active'] ?? 1) === 1;
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section data-tab-panel data-tab-panel-wide>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => trim((string) ($template['product_name'] ?? '')) !== '' ? (string) $template['product_name'] : t('Edit template'),
|
||||
'backHref' => lurl($closeTarget),
|
||||
'actions' => [
|
||||
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save', 'class' => 'primary', 'tone' => 'success'],
|
||||
['label' => t('Save & close'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save_close', 'class' => 'secondary outline'],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php if ($validationSummaryErrors): ?>
|
||||
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="security-template-form">
|
||||
<div class="app-details-card">
|
||||
<div class="security-field-row">
|
||||
<div class="security-field">
|
||||
<label><?php e(t('Product code')); ?></label>
|
||||
<input type="text" value="<?php e((string) ($template['product_code'] ?? '')); ?>" disabled>
|
||||
<small class="muted"><?php e(t('The product code cannot be changed after creation.')); ?></small>
|
||||
</div>
|
||||
<div class="security-field">
|
||||
<label for="security-template-name"><?php e(t('Product name')); ?> <span class="security-form-required">*</span></label>
|
||||
<input type="text" id="security-template-name" name="product_name" value="<?php e((string) ($template['product_name'] ?? '')); ?>" autocomplete="off">
|
||||
<?php if (!empty($errors['product_name'])): ?><p class="field-error"><?php e($errors['product_name']); ?></p><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<label class="security-check-toggle security-active-toggle">
|
||||
<input type="checkbox" name="active" value="1"<?php if ($isActive): ?> checked<?php endif; ?>>
|
||||
<span><?php e(t('Active (selectable when creating a security check)')); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="app-details-card">
|
||||
<h3><?php e(t('Technical checklist')); ?></h3>
|
||||
<p class="app-muted"><?php e(t('Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.')); ?></p>
|
||||
<?php if (!empty($errors['schema'])): ?><p class="field-error"><?php e($errors['schema']); ?></p><?php endif; ?>
|
||||
|
||||
<div id="security-schema-editor" data-app-component="security-template-schema-editor"
|
||||
data-input-name="tech_schema_json"
|
||||
data-label-section="<?php e(t('Section')); ?>"
|
||||
data-label-check="<?php e(t('Check item')); ?>"
|
||||
data-label-add-section="<?php e(t('Add section')); ?>"
|
||||
data-label-add-check="<?php e(t('Add check item')); ?>"
|
||||
data-label-remove="<?php e(t('Remove')); ?>"
|
||||
data-label-move-up="<?php e(t('Move up')); ?>"
|
||||
data-label-move-down="<?php e(t('Move down')); ?>"
|
||||
data-placeholder-section="<?php e(t('Section title')); ?>"
|
||||
data-placeholder-check="<?php e(t('Check item label')); ?>"
|
||||
data-placeholder-hint="<?php e(t('Hint (optional)')); ?>"
|
||||
data-placeholder-markdown="<?php e(t('Markdown guidance (optional) — shown in the checklist')); ?>"
|
||||
data-empty-text="<?php e(t('No checklist items yet. Add a check item to get started.')); ?>">
|
||||
</div>
|
||||
<input type="hidden" name="tech_schema_json" id="security-schema-json" value="">
|
||||
<script type="application/json" id="security-schema-initial"><?php gridJsonForJs(array_values($schemaItems)); ?></script>
|
||||
</div>
|
||||
|
||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
41
modules/security/pages/security/templates/filter-schema.php
Normal file
41
modules/security/pages/security/templates/filter-schema.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'search' => ['type' => 'string'],
|
||||
'active' => ['type' => 'string', 'default' => 'all'],
|
||||
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'order' => [
|
||||
'type' => 'order',
|
||||
'allowed' => ['id', 'product_code', 'product_name', 'active', 'version', 'updated_at'],
|
||||
'default' => 'product_name',
|
||||
],
|
||||
'dir' => ['type' => 'dir', 'default' => 'asc'],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'placeholder' => 'Search templates...',
|
||||
'input_id' => 'security-templates-search-input',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'active',
|
||||
'type' => 'select',
|
||||
'label' => 'Active',
|
||||
'input_id' => 'security-templates-active-filter',
|
||||
'default' => 'all',
|
||||
'normalize' => 'all_to_empty',
|
||||
'allowed' => [
|
||||
['id' => 'all', 'description' => 'All'],
|
||||
['id' => '1', 'description' => 'Active'],
|
||||
['id' => '0', 'description' => 'Inactive'],
|
||||
],
|
||||
'label_attributes' => ['data-filter-optional' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
41
modules/security/pages/security/templates/index().php
Normal file
41
modules/security/pages/security/templates/index().php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$query = $request->queryAll();
|
||||
|
||||
$filterSchema = require __DIR__ . '/filter-schema.php';
|
||||
$listFilterContext = gridBuildListFilterContext($filterSchema, [
|
||||
'query' => $query,
|
||||
'search_keys' => ['search'],
|
||||
]);
|
||||
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
|
||||
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
|
||||
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
|
||||
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
|
||||
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
|
||||
$searchConfig = $listFilterContext['searchConfig'];
|
||||
$schemaByKey = $listFilterContext['schemaByKey'];
|
||||
$filterChipMeta = [
|
||||
'search' => ['label' => t('Search'), 'type' => 'text'],
|
||||
'active' => [
|
||||
'label' => t('Active'),
|
||||
'type' => 'select',
|
||||
'default' => (string) ($schemaByKey['active']['default'] ?? 'all'),
|
||||
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['active'] ?? [])),
|
||||
],
|
||||
];
|
||||
|
||||
Buffer::set('title', t('Check templates'));
|
||||
Buffer::set('style_groups', json_encode(['security']));
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Security'), 'path' => 'security/checks'],
|
||||
['label' => t('Check templates')],
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
|
||||
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
|
||||
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
|
||||
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
|
||||
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
|
||||
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
|
||||
?>
|
||||
<?php
|
||||
$listTitle = t('Check templates');
|
||||
ob_start();
|
||||
?>
|
||||
<a role="button" class="app-action-success" href="<?php e(requestPathWithReturnTarget('security/templates/create', \MintyPHP\Http\Request::pathWithQuery())); ?>">
|
||||
<i class="bi bi-plus"></i> <?php e(t('New template')); ?>
|
||||
</a>
|
||||
<?php
|
||||
$listTitleActionsHtml = ob_get_clean();
|
||||
require templatePath('partials/app-list-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$filterUiNamespace = 'security-templates';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div class="app-list-table">
|
||||
<div id="security-templates-grid"></div>
|
||||
</div>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="application/json" id="page-config-security-templates"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('security/templates-data'),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'editBaseUrl' => lurl('security/templates/edit/'),
|
||||
'labels' => [
|
||||
'productName' => t('Product'),
|
||||
'productCode' => t('Code'),
|
||||
'itemCount' => t('Checklist items'),
|
||||
'active' => t('Active'),
|
||||
'updatedAt' => t('Updated'),
|
||||
'yes' => t('Yes'),
|
||||
'no' => t('No'),
|
||||
],
|
||||
]); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/security/js/pages/security-templates-index.js')); ?>"></script>
|
||||
82
modules/security/templates/aside-security-panel.phtml
Normal file
82
modules/security/templates/aside-security-panel.phtml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$securityNav = is_array($layoutNav['security.nav'] ?? null) ? $layoutNav['security.nav'] : [];
|
||||
$canViewChecks = !empty($securityNav['can_view_checks']);
|
||||
$canManageTemplates = !empty($securityNav['can_manage_templates']);
|
||||
$canManageSettings = !empty($securityNav['can_manage_settings']);
|
||||
|
||||
$checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true);
|
||||
$templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true);
|
||||
$settingsActive = navActive('security/settings', true);
|
||||
|
||||
$securityNavGroups = [
|
||||
[
|
||||
'key' => 'security-operations',
|
||||
'label' => t('Operations'),
|
||||
'icon' => 'bi-clipboard-check',
|
||||
'items' => [
|
||||
[
|
||||
'label' => t('Security checks'),
|
||||
'path' => 'security/checks',
|
||||
'active' => $checksActive,
|
||||
'visible' => $canViewChecks,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'security-administration',
|
||||
'label' => t('Administration'),
|
||||
'icon' => 'bi-sliders',
|
||||
'items' => [
|
||||
[
|
||||
'label' => t('Check templates'),
|
||||
'path' => 'security/templates',
|
||||
'active' => $templatesActive,
|
||||
'visible' => $canManageTemplates,
|
||||
],
|
||||
[
|
||||
'label' => t('Settings'),
|
||||
'path' => 'security/settings',
|
||||
'active' => $settingsActive,
|
||||
'visible' => $canManageSettings,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
?>
|
||||
<ul>
|
||||
<?php foreach ($securityNavGroups as $group):
|
||||
$visibleItems = array_values(array_filter($group['items'], static fn (array $item): bool => !empty($item['visible'])));
|
||||
if (!$visibleItems) {
|
||||
continue;
|
||||
}
|
||||
$groupIsActive = false;
|
||||
foreach ($visibleItems as $item) {
|
||||
if (!empty(($item['active'] ?? [])['isActive'])) {
|
||||
$groupIsActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<li class="app-sidebar-group app-sidebar-admin-group">
|
||||
<details data-details-key="<?php e($group['key']); ?>"<?php if ($groupIsActive): ?> open<?php endif; ?>>
|
||||
<summary>
|
||||
<i class="bi <?php e($group['icon']); ?>"></i>
|
||||
<span><?php e($group['label']); ?></span>
|
||||
</summary>
|
||||
<ul>
|
||||
<?php foreach ($visibleItems as $item):
|
||||
$active = $item['active'] ?? ['class' => '', 'aria' => ''];
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php e(lurl($item['path'])); ?>" class="<?php e($active['class'] ?? ''); ?>" <?php echo $active['aria'] ?? ''; // raw-html-ok: pre-built ARIA attribute from navActive() ?>>
|
||||
<?php e($item['label']); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
123
modules/security/templates/pdf/customer-report.phtml
Normal file
123
modules/security/templates/pdf/customer-report.phtml
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Customer-facing security check report (Dompdf).
|
||||
*
|
||||
* Hydrated as an isolated HTML string by SecurityReportPdfService — it only
|
||||
* sees $report (the flat, pre-built context). All dynamic values are escaped
|
||||
* with e(); labels go through t() so the report follows the request locale.
|
||||
*
|
||||
* @var array<string, mixed> $report
|
||||
*/
|
||||
$report = is_array($report ?? null) ? $report : [];
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||||
$steps = is_array($report['steps'] ?? null) ? $report['steps'] : [];
|
||||
$techSections = is_array($report['tech_sections'] ?? null) ? $report['tech_sections'] : [];
|
||||
|
||||
$percent = (int) ($summary['percent'] ?? 0);
|
||||
$techDone = (int) ($summary['tech_done'] ?? 0);
|
||||
$techTotal = (int) ($summary['tech_total'] ?? 0);
|
||||
$appName = trim((string) ($report['app_name'] ?? ''));
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="<?php e((string) (\MintyPHP\I18n::$locale ?? 'en')); ?>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title><?php e((string) ($report['title'] ?? '')); ?></title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "DejaVu Sans", Arial, sans-serif; color: #1a1a1a; font-size: 12px; line-height: 1.45; margin: 0; }
|
||||
h1 { font-size: 20px; margin: 0 0 2px; }
|
||||
h2 { font-size: 14px; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e2e2; }
|
||||
.muted { color: #6a6a6a; }
|
||||
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.header-logo { max-height: 48px; max-width: 200px; margin-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
.meta td { padding: 3px 0; vertical-align: top; }
|
||||
.meta td.label { color: #6a6a6a; width: 38%; }
|
||||
.summary { margin: 6px 0 4px; font-size: 13px; }
|
||||
.bar { height: 10px; background: #ececec; border-radius: 5px; overflow: hidden; margin-top: 6px; }
|
||||
.bar-fill { height: 10px; background: #2e7d32; }
|
||||
.checklist td { padding: 6px 8px; border-bottom: 1px solid #ededed; vertical-align: top; }
|
||||
.checklist td.state { width: 70px; white-space: nowrap; }
|
||||
.section-row td { background: #f5f5f5; font-weight: bold; padding: 6px 8px; }
|
||||
.ok { color: #2e7d32; font-weight: bold; }
|
||||
.pending { color: #b26a00; font-weight: bold; }
|
||||
.note { color: #6a6a6a; font-size: 11px; margin-top: 2px; }
|
||||
.footer { margin-top: 26px; padding-top: 8px; border-top: 1px solid #e2e2e2; color: #8a8a8a; font-size: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<img src="<?php e((string) ($report['logo_data_uri'] ?? '')); ?>" alt="" class="header-logo">
|
||||
<h1><?php e((string) ($report['title'] ?? '')); ?></h1>
|
||||
<div class="muted"><?php e(t('Generated on')); ?> <?php e((string) ($report['generated_at'] ?? '')); ?></div>
|
||||
</div>
|
||||
|
||||
<table class="meta">
|
||||
<tr><td class="label"><?php e(t('Customer')); ?></td><td><?php e((string) ($report['customer_name'] ?? '')); ?><?php if (($report['customer_no'] ?? '') !== ''): ?> <span class="muted">(<?php e((string) $report['customer_no']); ?>)</span><?php endif; ?></td></tr>
|
||||
<tr><td class="label"><?php e(t('Domain')); ?></td><td><?php e((string) ($report['domain'] ?? '')); ?></td></tr>
|
||||
<tr><td class="label"><?php e(t('Software product')); ?></td><td><?php e((string) ($report['product'] ?? '')); ?></td></tr>
|
||||
<tr><td class="label"><?php e(t('Status')); ?></td><td><?php e((string) ($report['status_label'] ?? '')); ?></td></tr>
|
||||
</table>
|
||||
|
||||
<h2><?php e(t('Result')); ?></h2>
|
||||
<div class="summary"><?php e(t('%d of %d checks completed', $techDone, $techTotal)); ?> (<?php e((string) $percent); ?>%)</div>
|
||||
<div class="bar"><div class="bar-fill" style="width: <?php e((string) $percent); ?>%;"></div></div>
|
||||
|
||||
<h2><?php e(t('Technical checklist')); ?></h2>
|
||||
<?php if ($techSections === []): ?>
|
||||
<p class="muted"><?php e(t('This product has no technical checklist items defined yet.')); ?></p>
|
||||
<?php else: ?>
|
||||
<table class="checklist">
|
||||
<?php foreach ($techSections as $section): ?>
|
||||
<?php if (trim((string) ($section['label'] ?? '')) !== ''): ?>
|
||||
<tr class="section-row"><td colspan="2"><?php e((string) $section['label']); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach (($section['items'] ?? []) as $item): ?>
|
||||
<tr>
|
||||
<td class="state">
|
||||
<?php if (!empty($item['done'])): ?>
|
||||
<span class="ok">✓ <?php e(t('Completed')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="pending"><?php e(t('Not completed')); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php e((string) ($item['label'] ?? '')); ?>
|
||||
<?php if (trim((string) ($item['note'] ?? '')) !== ''): ?>
|
||||
<div class="note"><?php e((string) $item['note']); ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php e(t('Process')); ?></h2>
|
||||
<table class="checklist">
|
||||
<?php foreach ($steps as $step): ?>
|
||||
<tr>
|
||||
<td class="state">
|
||||
<?php if (!empty($step['done'])): ?>
|
||||
<span class="ok">✓ <?php e(t('Completed')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="pending"><?php e(t('Open')); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php e((string) ($step['number'] ?? '')); ?>. <?php e((string) ($step['label'] ?? '')); ?>
|
||||
<?php if (trim((string) ($step['completed_at'] ?? '')) !== ''): ?>
|
||||
<span class="muted">· <?php e((string) $step['completed_at']); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<?php if ($appName !== ''): ?><?php e($appName); ?> · <?php endif; ?><?php e((string) ($report['title'] ?? '')); ?> · <?php e((string) ($report['generated_at'] ?? '')); ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
modules/security/templates/tech-markdown.phtml
Normal file
21
modules/security/templates/tech-markdown.phtml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Renders a single checkpoint's pre-sanitized Markdown guidance, collapsed by
|
||||
* default inside a <details> toggle.
|
||||
*
|
||||
* Lives in a partial (not the routed view) so the framework Analyzer permits the
|
||||
* raw echo. The HTML is produced by SecurityMarkdownGateway with html_input=escape
|
||||
* and unsafe links dropped, so it is safe to output verbatim.
|
||||
*
|
||||
* @var string $markdownHtml
|
||||
*/
|
||||
$markdownHtml = (string) ($markdownHtml ?? '');
|
||||
if ($markdownHtml === '') {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<details class="security-tech-markdown">
|
||||
<summary><i class="bi bi-info-circle" aria-hidden="true"></i> <?php e(t('Guidance')); ?></summary>
|
||||
<div class="security-tech-markdown-body"><?php echo $markdownHtml; // raw-html-ok: sanitized Markdown (html_input=escape, unsafe links dropped) via SecurityMarkdownGateway ?></div>
|
||||
</details>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityBcGatewayTest extends TestCase
|
||||
{
|
||||
private function makeGateway(bool $configured = false): SecurityBcGateway
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($configured);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
|
||||
$oauth = $this->createMock(SecurityOAuthTokenService::class);
|
||||
$session = $this->createMock(SessionStoreInterface::class);
|
||||
$session->method('all')->willReturn([]);
|
||||
|
||||
return new SecurityBcGateway($settings, $oauth, $session);
|
||||
}
|
||||
|
||||
public function testSearchCustomersReturnsEmptyForBlankQuery(): void
|
||||
{
|
||||
$this->assertSame([], $this->makeGateway()->searchCustomers(' '));
|
||||
}
|
||||
|
||||
public function testListDomainsForCustomerReturnsEmptyForBlankNumber(): void
|
||||
{
|
||||
$this->assertSame([], $this->makeGateway()->listDomainsForCustomer(''));
|
||||
}
|
||||
|
||||
public function testListDomainsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
// request() short-circuits to null before any network call when unconfigured.
|
||||
$this->assertSame([], $this->makeGateway(false)->listDomainsForCustomer('D10001'));
|
||||
}
|
||||
|
||||
public function testSearchCustomersRejectsInvalidCharacters(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->makeGateway(true)->searchCustomers("bad'); DROP TABLE--");
|
||||
}
|
||||
|
||||
public function testTestConnectionReportsIncompleteConfiguration(): void
|
||||
{
|
||||
$result = $this->makeGateway(false)->testConnection();
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('error', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityBcSettingsGatewayTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $values
|
||||
*/
|
||||
private function makeGateway(array $values = []): SecurityBcSettingsGateway
|
||||
{
|
||||
$metadata = $this->createMock(SettingsMetadataGateway::class);
|
||||
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
|
||||
$metadata->method('set')->willReturn(true);
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('encryptString')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
|
||||
$crypto->method('decryptString')->willReturnCallback(static fn (string $v): string => str_replace('enc:', '', $v));
|
||||
|
||||
return new SecurityBcSettingsGateway($metadata, $crypto);
|
||||
}
|
||||
|
||||
public function testAuthModeDefaultsToBasic(): void
|
||||
{
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_BASIC, $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testAuthModeReturnsOauth2WhenSet(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([SecurityBcSettingsGateway::KEY_AUTH_MODE => 'oauth2']);
|
||||
|
||||
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2, $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testBuildEntityUrlComposesCompanyAndEntity(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com/ODataV4/',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme GmbH',
|
||||
]);
|
||||
|
||||
$url = $gateway->buildEntityUrl('Integration_Customer_Card');
|
||||
|
||||
$this->assertSame("https://bc.example.com/ODataV4/Company('Acme%20GmbH')/Integration_Customer_Card", $url);
|
||||
}
|
||||
|
||||
public function testValidateConfigurationReportsMissingBasicCredentials(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
|
||||
]);
|
||||
|
||||
$missing = $gateway->validateConfiguration();
|
||||
|
||||
$this->assertNotEmpty($missing);
|
||||
$this->assertFalse($gateway->isConfigured());
|
||||
}
|
||||
|
||||
public function testIsConfiguredWhenBasicComplete(): void
|
||||
{
|
||||
$gateway = $this->makeGateway([
|
||||
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
|
||||
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
|
||||
SecurityBcSettingsGateway::KEY_BASIC_USER => 'svc',
|
||||
SecurityBcSettingsGateway::KEY_BASIC_PASSWORD_ENC => 'enc:secret',
|
||||
]);
|
||||
|
||||
$this->assertTrue($gateway->isConfigured());
|
||||
$this->assertSame('secret', $gateway->getBasicPassword());
|
||||
}
|
||||
|
||||
public function testSetODataBaseUrlRejectsNonHttps(): void
|
||||
{
|
||||
$gateway = $this->makeGateway();
|
||||
|
||||
$this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckProcessTest extends TestCase
|
||||
{
|
||||
public function testStepsContainSixOrderedSteps(): void
|
||||
{
|
||||
$steps = (new SecurityCheckProcess())->steps();
|
||||
|
||||
$this->assertCount(6, $steps);
|
||||
$this->assertSame('beauftragung', $steps[0]['key']);
|
||||
$this->assertSame(SecurityCheckProcess::STEP_PERFORM_CHECK, $steps[4]['key']);
|
||||
$this->assertSame('report', $steps[5]['key']);
|
||||
}
|
||||
|
||||
public function testManualStepKeysExcludeTechChecklistStep(): void
|
||||
{
|
||||
$keys = (new SecurityCheckProcess())->manualStepKeys();
|
||||
|
||||
$this->assertCount(5, $keys);
|
||||
$this->assertNotContains(SecurityCheckProcess::STEP_PERFORM_CHECK, $keys);
|
||||
$this->assertContains(SecurityCheckProcess::STEP_INFORMATION, $keys);
|
||||
}
|
||||
|
||||
public function testInfoStepFieldsAreDefined(): void
|
||||
{
|
||||
$fields = (new SecurityCheckProcess())->stepFields(SecurityCheckProcess::STEP_INFORMATION);
|
||||
|
||||
$this->assertNotEmpty($fields);
|
||||
$keys = array_column($fields, 'key');
|
||||
$this->assertContains('technical_contact', $keys);
|
||||
$this->assertContains('external_systems', $keys);
|
||||
}
|
||||
|
||||
public function testRequiredFieldKeysExcludeOptionalFields(): void
|
||||
{
|
||||
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);
|
||||
|
||||
$this->assertContains('technical_contact', $keys);
|
||||
$this->assertContains('deployment', $keys);
|
||||
$this->assertContains('system_info', $keys);
|
||||
$this->assertContains('external_systems', $keys);
|
||||
$this->assertNotContains('special_notes', $keys);
|
||||
}
|
||||
|
||||
public function testSchedulingAndBlockerStepsRequireTwoDatetimeFields(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$expected = [
|
||||
'scheduling' => ['golive_start', 'golive_end'],
|
||||
'blocker_appointment' => ['blocker_start', 'blocker_end'],
|
||||
];
|
||||
|
||||
foreach ($expected as $stepKey => $expectedKeys) {
|
||||
$fields = $process->stepFields($stepKey);
|
||||
$this->assertCount(2, $fields);
|
||||
foreach ($fields as $field) {
|
||||
$this->assertSame(SecurityCheckProcess::FIELD_DATETIME, $field['type']);
|
||||
$this->assertTrue($field['required']);
|
||||
}
|
||||
$this->assertSame($expectedKeys, $process->requiredFieldKeys($stepKey));
|
||||
}
|
||||
}
|
||||
|
||||
public function testTechCheckItemsExtractsOnlyCheckItemsWithKeys(): void
|
||||
{
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'section', 'label' => 'Group'],
|
||||
['type' => 'check', 'key' => 'tls', 'label' => 'TLS'],
|
||||
['type' => 'check', 'key' => '', 'label' => 'No key'],
|
||||
['type' => 'check', 'label' => 'Missing key'],
|
||||
'not-an-array',
|
||||
]];
|
||||
|
||||
$items = SecurityCheckProcess::techCheckItems($snapshot);
|
||||
|
||||
$this->assertCount(1, $items);
|
||||
$this->assertSame('tls', $items[0]['key']);
|
||||
}
|
||||
|
||||
public function testComputeProgressOpenWhenNothingDone(): void
|
||||
{
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], [], []);
|
||||
|
||||
$this->assertSame(5, $progress['manual_total']);
|
||||
$this->assertSame(0, $progress['tech_total']);
|
||||
$this->assertSame(0, $progress['done']);
|
||||
$this->assertSame(0, $progress['percent']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $progress['status']);
|
||||
$this->assertTrue($progress['step6_done']); // no tech items → trivially done
|
||||
}
|
||||
|
||||
public function testComputeProgressInProgressWhenPartiallyDone(): void
|
||||
{
|
||||
$processState = ['beauftragung' => ['done' => true]];
|
||||
|
||||
$progress = (new SecurityCheckProcess())->computeProgress($processState, [], []);
|
||||
|
||||
$this->assertSame(1, $progress['done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressCompletedWhenAllDone(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$techState = ['a' => ['done' => true], 'b' => ['done' => true]];
|
||||
|
||||
$progress = $process->computeProgress($processState, $snapshot, $techState);
|
||||
|
||||
$this->assertSame(7, $progress['total']); // 5 manual + 2 tech
|
||||
$this->assertSame(7, $progress['done']);
|
||||
$this->assertSame(100, $progress['percent']);
|
||||
$this->assertTrue($progress['step6_done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressStep6NotDoneWhenSomeTechItemsOpen(): void
|
||||
{
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], $snapshot, $techState);
|
||||
|
||||
$this->assertFalse($progress['step6_done']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
|
||||
}
|
||||
|
||||
public function testComputeProgressArchivedOverridesDerivedStatus(): void
|
||||
{
|
||||
$progress = (new SecurityCheckProcess())->computeProgress([], [], [], true);
|
||||
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $progress['status']);
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesMetWhenAllPrecedingStepsComplete(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
// The report step itself is intentionally left open — it does not gate itself.
|
||||
$processState[$key] = ['done' => $key !== SecurityCheckProcess::STEP_REPORT];
|
||||
}
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$this->assertTrue($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesNotMetWhenAManualStepIsOpen(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$processState['scheduling']['done'] = false; // one preceding step still open
|
||||
$techState = ['a' => ['done' => true]];
|
||||
|
||||
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesNotMetWhenTechChecklistIncomplete(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$snapshot = ['items' => [
|
||||
['type' => 'check', 'key' => 'a', 'label' => 'A'],
|
||||
['type' => 'check', 'key' => 'b', 'label' => 'B'],
|
||||
]];
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
$techState = ['a' => ['done' => true]]; // 'b' still open
|
||||
|
||||
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
|
||||
}
|
||||
|
||||
public function testReportPrerequisitesMetWithEmptyTechChecklist(): void
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true];
|
||||
}
|
||||
|
||||
// No tech items → checklist trivially complete; only the manual steps gate.
|
||||
$this->assertTrue($process->reportPrerequisitesMet($processState, [], []));
|
||||
}
|
||||
|
||||
public function testStatusVariantMapping(): void
|
||||
{
|
||||
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_OPEN));
|
||||
$this->assertSame('warning', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_IN_PROGRESS));
|
||||
$this->assertSame('success', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_COMPLETED));
|
||||
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_ARCHIVED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckService;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
|
||||
/**
|
||||
* @return array{0: SecurityCheckService, 1: SecurityCheckRepository, 2: SecurityCheckTemplateService}
|
||||
*/
|
||||
private function makeService(?SecurityCheckRepository $repo = null, ?SecurityCheckTemplateService $templates = null): array
|
||||
{
|
||||
$repo = $repo ?? $this->createMock(SecurityCheckRepository::class);
|
||||
$templates = $templates ?? $this->createMock(SecurityCheckTemplateService::class);
|
||||
$service = new SecurityCheckService($repo, $templates, new SecurityCheckProcess());
|
||||
|
||||
return [$service, $repo, $templates];
|
||||
}
|
||||
|
||||
public function testCreateRequiresCustomerDomainAndProduct(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->expects($this->never())->method('insert');
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('debitor_no', $result['errors']);
|
||||
$this->assertArrayHasKey('domain_no', $result['errors']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateFailsWhenTemplateMissing(): void
|
||||
{
|
||||
$templates = $this->createMock(SecurityCheckTemplateService::class);
|
||||
$templates->method('findByCode')->willReturn(null);
|
||||
[$service] = $this->makeService(null, $templates);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [
|
||||
'debitor_no' => 'D1',
|
||||
'domain_no' => 'DNS1',
|
||||
'product_code' => 'intranet',
|
||||
], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateSucceedsAndSnapshotsSchema(): void
|
||||
{
|
||||
$templates = $this->createMock(SecurityCheckTemplateService::class);
|
||||
$templates->method('findByCode')->willReturn(['id' => 7, 'product_name' => 'Intranet', 'active' => 1]);
|
||||
$templates->method('decodeSchema')->willReturn(['version' => 2, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
|
||||
|
||||
$captured = null;
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('insert')->willReturnCallback(function (int $tenantId, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return 42;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo, $templates);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, [
|
||||
'debitor_no' => 'D1',
|
||||
'debitor_name' => 'Acme',
|
||||
'domain_no' => 'DNS1',
|
||||
'domain_url' => 'acme.de',
|
||||
'product_code' => 'intranet',
|
||||
'owner_user_id' => 9,
|
||||
], 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(42, $result['id']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $captured['status']);
|
||||
$this->assertSame(7, $captured['template_id']);
|
||||
$this->assertSame('Intranet', $captured['product_name']);
|
||||
$snapshot = json_decode((string) $captured['tech_schema_snapshot_json'], true);
|
||||
$this->assertSame('tls', $snapshot['items'][0]['key']);
|
||||
}
|
||||
|
||||
public function testSaveStateStampsCompletionAndDerivesCompletedStatus(): void
|
||||
{
|
||||
$snapshot = json_encode(['version' => 1, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => $snapshot,
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$process = new SecurityCheckProcess();
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$step = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$step[$key] = ['done' => '1', 'note' => ''];
|
||||
}
|
||||
$step[SecurityCheckProcess::STEP_INFORMATION]['fields'] = [
|
||||
'technical_contact' => 'Jane',
|
||||
'deployment' => 'Cloud',
|
||||
'system_info' => 'PHP 8.5',
|
||||
'external_systems' => 'https://api.example.test',
|
||||
];
|
||||
$step['scheduling']['fields'] = [
|
||||
'golive_start' => '2026-07-01T09:00',
|
||||
'golive_end' => '2026-07-01T12:00',
|
||||
];
|
||||
$step['blocker_appointment']['fields'] = [
|
||||
'blocker_start' => '2026-07-01T08:00',
|
||||
'blocker_end' => '2026-07-02T18:00',
|
||||
];
|
||||
$input = [
|
||||
'step' => $step,
|
||||
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
|
||||
];
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, $input, 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertTrue($process_state['beauftragung']['done']);
|
||||
$this->assertSame(77, $process_state['beauftragung']['by']);
|
||||
$this->assertNotEmpty($process_state['beauftragung']['at']);
|
||||
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
|
||||
|
||||
$tech_state = json_decode((string) $captured['tech_state_json'], true);
|
||||
$this->assertTrue($tech_state['tls']['done']);
|
||||
$this->assertSame('verified', $tech_state['tls']['note']);
|
||||
}
|
||||
|
||||
public function testSaveStateGatesInformationStepUntilRequiredFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => ['technical_contact' => 'Jane'], // deployment / system_info / external_systems missing
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
// Saved (values persisted) but the step is held open + a warning is returned.
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertFalse($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
|
||||
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
|
||||
$this->assertNotSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
|
||||
}
|
||||
|
||||
public function testSaveStateCompletesInformationStepWhenAllRequiredFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => [
|
||||
'technical_contact' => 'Jane',
|
||||
'deployment' => 'Cloud',
|
||||
'system_info' => 'PHP 8.5',
|
||||
'external_systems' => 'https://api.example.test',
|
||||
// special_notes intentionally left empty — it is optional
|
||||
],
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayNotHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertTrue($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
|
||||
$this->assertSame(77, $process_state[SecurityCheckProcess::STEP_INFORMATION]['by']);
|
||||
}
|
||||
|
||||
public function testSaveStateGatesDatetimeStepUntilBothFieldsFilled(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_OPEN,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$captured = null;
|
||||
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
// Scheduling needs both datetimes; with only one filled it stays open.
|
||||
$result = $service->saveState(self::TENANT_ID, 1, [
|
||||
'step' => ['scheduling' => [
|
||||
'done' => '1',
|
||||
'note' => '',
|
||||
'fields' => ['golive_start' => '2026-07-01T09:00'], // golive_end missing
|
||||
]],
|
||||
'tech' => [],
|
||||
], 77);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertArrayHasKey('warning', $result);
|
||||
|
||||
$process_state = json_decode((string) $captured['process_state_json'], true);
|
||||
$this->assertFalse($process_state['scheduling']['done']);
|
||||
$this->assertSame('2026-07-01T09:00', $process_state['scheduling']['fields']['golive_start']);
|
||||
}
|
||||
|
||||
public function testSaveStateRejectsArchivedCheck(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_ARCHIVED,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
$repo->expects($this->never())->method('updateState');
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->saveState(self::TENANT_ID, 1, ['step' => [], 'tech' => []], 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('general', $result['errors']);
|
||||
}
|
||||
|
||||
public function testSetArchivedUpdatesStatusToArchived(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('findById')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
||||
'process_state_json' => '{}',
|
||||
'tech_schema_snapshot_json' => '{}',
|
||||
'tech_state_json' => '{}',
|
||||
]);
|
||||
|
||||
$capturedStatus = null;
|
||||
$repo->method('updateStatus')->willReturnCallback(function (int $tenantId, int $id, string $status) use (&$capturedStatus) {
|
||||
$capturedStatus = $status;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
|
||||
$result = $service->setArchived(self::TENANT_ID, 1, true, 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $capturedStatus);
|
||||
}
|
||||
|
||||
public function testListPagedDelegatesToRepository(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('listPaged')->willReturn(['total' => 3, 'rows' => [['id' => 1]]]);
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
$result = $service->listPaged(self::TENANT_ID, ['search' => 'x']);
|
||||
|
||||
$this->assertSame(3, $result['total']);
|
||||
$this->assertCount(1, $result['rows']);
|
||||
}
|
||||
|
||||
public function testListAssignableUsersDelegatesToRepository(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckRepository::class);
|
||||
$repo->method('listTenantUsers')->willReturn([
|
||||
['id' => 3, 'display_name' => 'Alice'],
|
||||
['id' => 7, 'display_name' => 'Bob'],
|
||||
]);
|
||||
|
||||
[$service] = $this->makeService($repo);
|
||||
$users = $service->listAssignableUsers(self::TENANT_ID);
|
||||
|
||||
$this->assertCount(2, $users);
|
||||
$this->assertSame('Alice', $users[0]['display_name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityCheckTemplateServiceTest extends TestCase
|
||||
{
|
||||
private const TENANT_ID = 1;
|
||||
|
||||
public function testCreateRejectsEmptyCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->expects($this->never())->method('insert');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, '', 'Name', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateRejectsInvalidCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'has spaces!', 'Name', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateRejectsDuplicateCode(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('codeExists')->willReturn(true);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_code', $result['errors']);
|
||||
}
|
||||
|
||||
public function testCreateSucceeds(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('codeExists')->willReturn(false);
|
||||
$repo->method('insert')->willReturn(12);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(12, $result['id']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsMissingTemplate(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(null);
|
||||
$repo->expects($this->never())->method('updateSchema');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 99, [['type' => 'check', 'label' => 'X']], 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaSlugifiesKeysBumpsVersionAndKeepsSections(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 3]);
|
||||
|
||||
$capturedJson = null;
|
||||
$capturedVersion = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json, int $version) use (&$capturedJson, &$capturedVersion) {
|
||||
$capturedJson = $json;
|
||||
$capturedVersion = $version;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'section', 'label' => 'Intranet'],
|
||||
['type' => 'check', 'label' => 'TLS überall erzwungen', 'hint' => 'check certs'],
|
||||
], 1);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $capturedVersion);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame(4, $data['version']);
|
||||
$this->assertSame('section', $data['items'][0]['type']);
|
||||
$this->assertSame('check', $data['items'][1]['type']);
|
||||
$this->assertSame('tls_ueberall_erzwungen', $data['items'][1]['key']);
|
||||
$this->assertSame('check certs', $data['items'][1]['hint']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaPersistsMarkdownAndOmitsEmpty(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$capturedJson = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
|
||||
$capturedJson = $json;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'check', 'label' => 'TLS', 'markdown' => '**Verify** the certificate chain.'],
|
||||
['type' => 'check', 'label' => 'Backup', 'markdown' => ' '],
|
||||
], 1);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame('**Verify** the certificate chain.', $data['items'][0]['markdown']);
|
||||
$this->assertArrayNotHasKey('markdown', $data['items'][1]);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaDeduplicatesKeys(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$capturedJson = null;
|
||||
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
|
||||
$capturedJson = $json;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$service->saveTechSchema(self::TENANT_ID, 1, [
|
||||
['type' => 'check', 'label' => 'Backup'],
|
||||
['type' => 'check', 'label' => 'Backup'],
|
||||
], 1);
|
||||
|
||||
$data = json_decode((string) $capturedJson, true);
|
||||
$this->assertSame('backup', $data['items'][0]['key']);
|
||||
$this->assertSame('backup_2', $data['items'][1]['key']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsItemWithoutLabel(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
$repo->expects($this->never())->method('updateSchema');
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, [['type' => 'check', 'label' => '']], 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('schema', $result['errors']);
|
||||
}
|
||||
|
||||
public function testSaveTechSchemaRejectsTooManyItems(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
|
||||
|
||||
$items = [];
|
||||
for ($i = 0; $i <= SecurityCheckTemplateService::MAX_ITEMS; $i++) {
|
||||
$items[] = ['type' => 'check', 'label' => 'Item ' . $i];
|
||||
}
|
||||
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
$result = $service->saveTechSchema(self::TENANT_ID, 1, $items, 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('schema', $result['errors']);
|
||||
}
|
||||
|
||||
public function testUpdateMetaRejectsEmptyName(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$repo->expects($this->never())->method('updateMeta');
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$result = $service->updateMeta(self::TENANT_ID, 1, '', true, 1);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertArrayHasKey('product_name', $result['errors']);
|
||||
}
|
||||
|
||||
public function testDecodeSchemaReturnsItems(): void
|
||||
{
|
||||
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
|
||||
$service = new SecurityCheckTemplateService($repo);
|
||||
|
||||
$decoded = $service->decodeSchema([
|
||||
'version' => 5,
|
||||
'tech_schema_json' => json_encode(['version' => 5, 'items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]]),
|
||||
]);
|
||||
|
||||
$this->assertSame(5, $decoded['version']);
|
||||
$this->assertCount(1, $decoded['items']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityMarkdownGatewayTest extends TestCase
|
||||
{
|
||||
public function testRendersBasicMarkdownToHtml(): void
|
||||
{
|
||||
$html = (new SecurityMarkdownGateway())->toHtml("**bold** text\n\n- one\n- two");
|
||||
|
||||
$this->assertStringContainsString('<strong>bold</strong>', $html);
|
||||
$this->assertStringContainsString('<li>one</li>', $html);
|
||||
$this->assertStringContainsString('<li>two</li>', $html);
|
||||
}
|
||||
|
||||
public function testEscapesRawHtmlSoItCannotInjectMarkup(): void
|
||||
{
|
||||
$html = (new SecurityMarkdownGateway())->toHtml('<script>alert(1)</script>');
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $html);
|
||||
$this->assertStringContainsString('<script>', $html);
|
||||
}
|
||||
|
||||
public function testEmptyOrWhitespaceInputReturnsEmptyString(): void
|
||||
{
|
||||
$gateway = new SecurityMarkdownGateway();
|
||||
|
||||
$this->assertSame('', $gateway->toHtml(''));
|
||||
$this->assertSame('', $gateway->toHtml(" \n "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
|
||||
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
|
||||
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityOAuthTokenServiceTest extends TestCase
|
||||
{
|
||||
public function testReturnsNullWhenAuthModeIsBasic(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
$repo->expects($this->never())->method('findValidToken');
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertNull($service->getAccessToken(1));
|
||||
}
|
||||
|
||||
public function testReturnsNullForInvalidTenant(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertNull($service->getAccessToken(0));
|
||||
}
|
||||
|
||||
public function testReturnsCachedTokenWhenAvailable(): void
|
||||
{
|
||||
$settings = $this->createMock(SecurityBcSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
|
||||
$repo = $this->createMock(SecurityTokenRepository::class);
|
||||
$repo->method('findValidToken')->willReturn('cached-bearer-token');
|
||||
|
||||
$service = new SecurityOAuthTokenService($settings, $repo);
|
||||
|
||||
$this->assertSame('cached-bearer-token', $service->getAccessToken(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Security\Service;
|
||||
|
||||
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
|
||||
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SecurityReportPdfServiceTest extends TestCase
|
||||
{
|
||||
private function makeService(): SecurityReportPdfService
|
||||
{
|
||||
$branding = $this->createMock(BrandingLogoService::class);
|
||||
$branding->method('hasLogo')->willReturn(false);
|
||||
|
||||
// Null tenant-logo service: render falls back to the static brand asset.
|
||||
return new SecurityReportPdfService(new SecurityCheckProcess(), $branding, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function enrichedCheck(): array
|
||||
{
|
||||
$process = new SecurityCheckProcess();
|
||||
$processState = [];
|
||||
foreach ($process->manualStepKeys() as $key) {
|
||||
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
|
||||
}
|
||||
$snapshot = ['version' => 1, 'items' => [
|
||||
['type' => 'section', 'label' => 'Transport'],
|
||||
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
|
||||
['type' => 'check', 'key' => 'hsts', 'label' => 'HSTS header'],
|
||||
['type' => 'section', 'label' => 'Application'],
|
||||
['type' => 'check', 'key' => 'csrf', 'label' => 'CSRF protection'],
|
||||
]];
|
||||
$techState = [
|
||||
'tls' => ['done' => true, 'note' => 'TLS 1.3'],
|
||||
'hsts' => ['done' => true, 'note' => ''],
|
||||
'csrf' => ['done' => false, 'note' => 'pending review'],
|
||||
];
|
||||
|
||||
return [
|
||||
'debitor_name' => 'Acme GmbH',
|
||||
'debitor_no' => 'D-100',
|
||||
'domain_url' => 'acme.de',
|
||||
'domain_no' => 'DNS-1',
|
||||
'product_name' => 'Intranet',
|
||||
'product_code' => 'intranet',
|
||||
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
|
||||
'process_state' => $processState,
|
||||
'tech_schema_snapshot' => $snapshot,
|
||||
'tech_state' => $techState,
|
||||
'progress' => $process->computeProgress($processState, $snapshot, $techState),
|
||||
];
|
||||
}
|
||||
|
||||
public function testBuildReportContextMapsCheckDataIntoRenderShape(): void
|
||||
{
|
||||
$context = $this->makeService()->buildReportContext($this->enrichedCheck());
|
||||
|
||||
$this->assertSame('Acme GmbH', $context['customer_name']);
|
||||
$this->assertSame('D-100', $context['customer_no']);
|
||||
$this->assertSame('acme.de', $context['domain']);
|
||||
$this->assertSame('Intranet', $context['product']);
|
||||
$this->assertNotSame('', $context['title']);
|
||||
|
||||
// Summary mirrors the progress math (3 tech items, 2 done).
|
||||
$this->assertSame(3, $context['summary']['tech_total']);
|
||||
$this->assertSame(2, $context['summary']['tech_done']);
|
||||
|
||||
// Five manual milestones, numbered with the technical step skipped.
|
||||
$this->assertCount(5, $context['steps']);
|
||||
$this->assertSame(1, $context['steps'][0]['number']);
|
||||
$this->assertTrue($context['steps'][0]['done']);
|
||||
$this->assertSame('2026-06-18 10:00:00', $context['steps'][0]['completed_at']);
|
||||
// 'report' is step 6 (the tech checklist step 5 is skipped from the milestone list).
|
||||
$this->assertSame(6, $context['steps'][4]['number']);
|
||||
|
||||
// Tech items grouped under their sections, carrying state + notes.
|
||||
$this->assertCount(2, $context['tech_sections']);
|
||||
$this->assertSame('Transport', $context['tech_sections'][0]['label']);
|
||||
$this->assertCount(2, $context['tech_sections'][0]['items']);
|
||||
$this->assertSame('TLS enforced', $context['tech_sections'][0]['items'][0]['label']);
|
||||
$this->assertTrue($context['tech_sections'][0]['items'][0]['done']);
|
||||
$this->assertSame('TLS 1.3', $context['tech_sections'][0]['items'][0]['note']);
|
||||
$this->assertSame('Application', $context['tech_sections'][1]['label']);
|
||||
$this->assertFalse($context['tech_sections'][1]['items'][0]['done']);
|
||||
}
|
||||
|
||||
public function testBuildReportContextHandlesEmptyCheckSafely(): void
|
||||
{
|
||||
$context = $this->makeService()->buildReportContext([]);
|
||||
|
||||
$this->assertSame('', $context['customer_name']);
|
||||
$this->assertSame('', $context['domain']);
|
||||
$this->assertSame([], $context['tech_sections']);
|
||||
$this->assertSame(0, $context['summary']['total']);
|
||||
$this->assertSame(0, $context['summary']['percent']);
|
||||
// The fixed process always yields its five manual milestones, all open.
|
||||
$this->assertCount(5, $context['steps']);
|
||||
$this->assertFalse($context['steps'][0]['done']);
|
||||
}
|
||||
|
||||
public function testBuildReportFilenameSlugifiesCustomerAndDomain(): void
|
||||
{
|
||||
$name = $this->makeService()->buildReportFilename($this->enrichedCheck());
|
||||
|
||||
$this->assertSame('security-check-report-acme-gmbh-acme-de.pdf', $name);
|
||||
}
|
||||
|
||||
public function testBuildReportFilenameFallsBackWhenNoIdentifiers(): void
|
||||
{
|
||||
$name = $this->makeService()->buildReportFilename([]);
|
||||
|
||||
$this->assertSame('security-check-report.pdf', $name);
|
||||
}
|
||||
|
||||
public function testRenderCustomerReportPdfProducesPdfBytes(): void
|
||||
{
|
||||
$pdf = $this->makeService()->renderCustomerReportPdf($this->enrichedCheck(), [
|
||||
'app_name' => 'CoreCore',
|
||||
]);
|
||||
|
||||
$this->assertNotSame('', $pdf);
|
||||
$this->assertStringStartsWith('%PDF-', $pdf);
|
||||
}
|
||||
}
|
||||
547
modules/security/web/css/security.css
Normal file
547
modules/security/web/css/security.css
Normal file
@@ -0,0 +1,547 @@
|
||||
/* Security module styles. Prefix: security-. */
|
||||
|
||||
.security-form-required {
|
||||
color: var(--app-color-danger, #b42318);
|
||||
}
|
||||
|
||||
/* --- Check workspace: summary + progress --- */
|
||||
.security-check-summary {
|
||||
/* Horizontal padding comes from `.app-details-container > section > *`;
|
||||
only set vertical padding so the summary stays aligned with the rest. */
|
||||
padding-block: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.security-check-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-check-meta dt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--app-text-muted, #667085);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.security-check-meta dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.security-check-progress {
|
||||
/* Full-width progress bar directly under the info section. */
|
||||
padding-block: 0 1.25rem;
|
||||
}
|
||||
|
||||
.security-check-progress-bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-muted-border-color, #eaecf0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.security-check-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--app-notice-success-border-color, #079455);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.security-check-progress-label {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--app-muted-color, #667085);
|
||||
}
|
||||
|
||||
/* --- Steps: each step is its own separated card --- */
|
||||
.security-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.security-step {
|
||||
/* Reset Pico's <article> card chrome (extra margin + shadow); we draw our own border. */
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--app-card-border-color, #e4e7ec);
|
||||
border-radius: var(--app-border-radius, 8px);
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--app-card-background-color, #fff);
|
||||
}
|
||||
|
||||
.security-step.is-done {
|
||||
border-color: var(--app-notice-success-border-color, #079455);
|
||||
background: var(--app-notice-success-background-color, #f6fef9);
|
||||
}
|
||||
|
||||
.security-step-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
/* Reset Pico's <article> > <header> chrome so it isn't a boxed-off sub-section. */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.security-step-number {
|
||||
flex: 0 0 1.75rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 999px;
|
||||
background: var(--app-muted-border-color, #eaecf0);
|
||||
color: var(--app-muted-color, #475467);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security-step.is-done .security-step-number {
|
||||
background: var(--app-notice-success-border-color, #079455);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.security-check-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-step-toggle {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.security-step-toggle span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* Match step 5's <h3> headline size so all step card titles are consistent. */
|
||||
.security-step-toggle strong {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.security-check-toggle input[type="checkbox"] {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
flex-shrink: 0;
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.security-completion-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--app-notice-success-color, #079455);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.security-item-note {
|
||||
width: 100%;
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
|
||||
/* Align the per-step note with the indented step fields
|
||||
(start past the number badge + gap, matching `.security-step-fields`). */
|
||||
.security-step > .security-item-note {
|
||||
margin-left: 2.5rem;
|
||||
width: calc(100% - 2.5rem);
|
||||
}
|
||||
|
||||
.security-step-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem 1rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
/* Long-form text fields take a full row; compact fields (datetimes) sit side by side. */
|
||||
.security-step-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.security-step-field-datetime {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.security-step-field label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--app-muted-color, #475467);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.security-step-field textarea,
|
||||
.security-step-field input[type="datetime-local"] {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.security-step-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.security-step-field-datetime {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hint shown (by the workspace JS) while required info fields are still empty. */
|
||||
.security-step-gate-hint:not([hidden]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.6rem 0 0;
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--app-notice-warning-color, #ad7122);
|
||||
}
|
||||
|
||||
/* "Generate PDF customer report" action in the final (report) step. Indented to
|
||||
2.5rem to line up under the step header text, like the fields/notes above. */
|
||||
.security-report-action {
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.security-report-action > .secondary {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.security-report-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* --- Technical checklist --- */
|
||||
.security-step-tech .security-tech-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
/* Indent to 2.5rem so checklist items line up under the step header text
|
||||
(past the number badge + gap), matching the other steps' fields/notes. */
|
||||
padding: 0 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.security-step-empty {
|
||||
margin: 0.75rem 0 0;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.security-tech-section {
|
||||
font-weight: 600;
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
color: var(--app-text, #1d2939);
|
||||
}
|
||||
|
||||
.security-tech-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--app-border-subtle, #f2f4f7);
|
||||
}
|
||||
|
||||
.security-tech-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.security-tech-hint {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Rendered Markdown guidance authored on the template — collapsed by default in
|
||||
a <details> callout (left accent + info tint) so it clearly reads as the info
|
||||
for this checkpoint without crowding the form. */
|
||||
.security-tech-markdown {
|
||||
flex-basis: 100%;
|
||||
margin: 0.4rem 0 0.1rem 1.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-left: 3px solid var(--app-notice-info-border-color, #6b7e99);
|
||||
border-radius: 0 6px 6px 0;
|
||||
background: var(--app-notice-info-background-color, #eef1f6);
|
||||
font-size: 0.85rem;
|
||||
color: var(--app-color, #1d2939);
|
||||
}
|
||||
|
||||
.security-tech-markdown > summary {
|
||||
font-weight: 500;
|
||||
color: var(--app-notice-info-color, #4a5a73);
|
||||
}
|
||||
|
||||
.security-tech-markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.security-tech-markdown-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.security-tech-markdown-body :where(ul, ol) {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.security-tech-markdown-body pre {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--app-card-background-color, #fff);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.security-tech-item .security-item-note {
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* --- Danger zone --- */
|
||||
.security-danger-zone {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Inner wrapper carries the border so it inherits the same 2rem inset
|
||||
(via `.app-details-container > section > *`) as the steps container. */
|
||||
.security-danger-zone-inner {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--app-notice-error-border-color, #fda29b);
|
||||
border-radius: var(--app-border-radius, 8px);
|
||||
background: var(--app-notice-error-background-color, #fffbfa);
|
||||
}
|
||||
|
||||
.security-danger-zone-inner h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.security-danger-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.security-danger-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-danger-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- Template edit + fields --- */
|
||||
.security-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.security-field {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.security-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.security-field input,
|
||||
.security-field select {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The core `input + small` helper rule (app-shell, @layer layout) applies a
|
||||
negative margin-top that assumes the input keeps its default bottom margin.
|
||||
We reset that margin to 0 above, so without this the negative pull drags the
|
||||
hint up into the input. Restore the app's intended helper-text gap. */
|
||||
.security-field input + small,
|
||||
.security-field select + small {
|
||||
margin-top: calc(var(--app-spacing) * 0.25);
|
||||
}
|
||||
|
||||
.security-active-toggle {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Schema editor --- */
|
||||
.security-schema-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.security-schema-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--app-card-border-color, #e4e7ec);
|
||||
border-radius: 6px;
|
||||
background: var(--app-card-background-color, #fff);
|
||||
}
|
||||
|
||||
.security-schema-item-section {
|
||||
background: var(--app-card-sectioning-background-color, #f9fafb);
|
||||
}
|
||||
|
||||
.security-schema-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.security-schema-markdown {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.security-schema-type {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.security-schema-label {
|
||||
flex: 1 1 40%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-schema-hint {
|
||||
flex: 1 1 30%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-schema-controls {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.security-schema-controls button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-schema-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.security-schema-toolbar button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.security-schema-item-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.security-schema-label,
|
||||
.security-schema-hint {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Creation wizard (check + template create views) ---
|
||||
The .app-wizard-* primitive is shared in spirit with other modules but its
|
||||
styles are not in core — they live in each module's own stylesheet. This
|
||||
module is standalone (requires: []) and never loads another module's CSS, so
|
||||
without these rules the header collapses to block flow (back arrow stacked
|
||||
above the title). Kept in sync with the helpdesk wizard. Single-step here, so
|
||||
the step-indicator rules are intentionally omitted. */
|
||||
.app-wizard-content {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.app-wizard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--app-spacing) * 0.75);
|
||||
margin-bottom: calc(var(--app-spacing) * 1.25);
|
||||
}
|
||||
|
||||
.app-wizard-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--app-border-radius);
|
||||
color: var(--app-muted-color);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.app-wizard-back:hover {
|
||||
background: var(--app-background-muted, #f8f9fa);
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.app-wizard-title {
|
||||
font-size: var(--text-xl, 1.25rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-wizard-card {
|
||||
background: var(--app-card-background-color, #fff);
|
||||
border: 1px solid var(--app-card-border-color, #e5e7eb);
|
||||
border-radius: calc(var(--app-border-radius) * 2);
|
||||
padding: calc(var(--app-spacing) * 1.5);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.app-wizard-card-heading {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.app-wizard-card-description {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--app-muted-color);
|
||||
margin: 0 0 calc(var(--app-spacing) * 1.25);
|
||||
}
|
||||
|
||||
.app-wizard-field-group {
|
||||
margin-bottom: calc(var(--app-spacing) * 1.25);
|
||||
}
|
||||
|
||||
.app-wizard-field-group > label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.app-wizard-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: calc(var(--app-spacing) * 1.5);
|
||||
padding-top: calc(var(--app-spacing) * 1.25);
|
||||
border-top: 1px solid var(--app-card-border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.app-wizard-card {
|
||||
padding: var(--app-spacing);
|
||||
}
|
||||
}
|
||||
80
modules/security/web/js/pages/security-checks-index.js
Normal file
80
modules/security/web/js/pages/security-checks-index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
createListPageModule({
|
||||
configId: 'security-checks',
|
||||
moduleId: 'security-checks',
|
||||
missingGridMessage: 'Security checks grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Security checks grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#security-checks-grid',
|
||||
dataUrl: config.dataUrl || 'security/checks-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.customer || 'Customer',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const text = escapeHtml(String(cell?.text || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
if (text === '') {
|
||||
return gridjs.html('<span class="text-muted">—</span>');
|
||||
}
|
||||
if (url === '') {
|
||||
return gridjs.html(text);
|
||||
}
|
||||
return gridjs.html(`<a href="${escapeHtml(withCurrentListReturn(url))}">${text}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.domain || 'Domain', sort: true },
|
||||
{ name: labels.product || 'Software product', sort: true },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const label = escapeHtml(cell?.label || '');
|
||||
const variant = cell?.variant || 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.owner || 'Owner', sort: true },
|
||||
{ name: labels.createdAt || 'Created', sort: true },
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['debitor_name', 'domain_url', 'product_name', 'status', null, 'created_at', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ text: row.debitor_name || '', url: row.edit_url || '' },
|
||||
row.domain_url || '',
|
||||
row.product_name || '',
|
||||
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
|
||||
row.owner_name || '',
|
||||
row.created_at || '',
|
||||
row.edit_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[6]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const listResult = initListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#security-checks-search-input'],
|
||||
},
|
||||
});
|
||||
|
||||
return listResult?.gridConfig || null;
|
||||
},
|
||||
});
|
||||
79
modules/security/web/js/pages/security-templates-index.js
Normal file
79
modules/security/web/js/pages/security-templates-index.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
createListPageModule({
|
||||
configId: 'security-templates',
|
||||
moduleId: 'security-templates',
|
||||
missingGridMessage: 'Security templates grid init failed: Grid.js missing',
|
||||
initErrorMessage: 'Security templates grid init failed',
|
||||
setup: ({ config, gridjs, appBase, initListPage }) => {
|
||||
const labels = config.labels || {};
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#security-templates-grid',
|
||||
dataUrl: config.dataUrl || 'security/templates-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.productName || 'Product',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const text = escapeHtml(String(cell?.text || ''));
|
||||
const url = String(cell?.url || '').trim();
|
||||
if (text === '') {
|
||||
return gridjs.html('<span class="text-muted">—</span>');
|
||||
}
|
||||
if (url === '') {
|
||||
return gridjs.html(text);
|
||||
}
|
||||
return gridjs.html(`<a href="${escapeHtml(withCurrentListReturn(url))}">${text}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.productCode || 'Code', sort: true },
|
||||
{ name: labels.itemCount || 'Checklist items', sort: false },
|
||||
{
|
||||
name: labels.active || 'Active',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const active = cell === true || cell === 'true';
|
||||
const label = active ? (labels.yes || 'Yes') : (labels.no || 'No');
|
||||
const variant = active ? 'success' : 'neutral';
|
||||
return gridjs.html(`<span class="badge" data-variant="${variant}">${escapeHtml(label)}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.updatedAt || 'Updated', sort: true },
|
||||
{ name: 'edit_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['product_name', 'product_code', null, 'active', 'updated_at', null],
|
||||
paginationLimit: 20,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ text: row.product_name || row.product_code || '', url: row.edit_url || '' },
|
||||
row.product_code || '',
|
||||
String(row.item_count ?? 0),
|
||||
row.active === true,
|
||||
row.updated_at || '',
|
||||
row.edit_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const listResult = initListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#security-templates-search-input'],
|
||||
},
|
||||
});
|
||||
|
||||
return listResult?.gridConfig || null;
|
||||
},
|
||||
});
|
||||
108
modules/security/web/js/security-check-workspace.js
Normal file
108
modules/security/web/js/security-check-workspace.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Security check workspace — light client-side enhancement of the checklist form.
|
||||
*
|
||||
* Progress is authoritative on the server (recomputed on save); this only gives
|
||||
* live visual feedback: toggles the done-styling on a row and updates the
|
||||
* progress bar width as items are checked. No business logic.
|
||||
*/
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const SELECTOR = '[data-app-component="security-check-workspace"]';
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveForm = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector(SELECTOR);
|
||||
};
|
||||
|
||||
export function initSecurityCheckWorkspace(root = document) {
|
||||
const form = resolveForm(root);
|
||||
if (!form) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const section = form.closest('section') || document;
|
||||
const fill = section.querySelector('.security-check-progress-fill');
|
||||
const doneToggles = () => Array.from(form.querySelectorAll('input[type="checkbox"][name$="[done]"]'));
|
||||
|
||||
// Step gating: a step's done-checkbox can only be ticked once all of its
|
||||
// required fields are filled. Authoritative gate lives on the server; this
|
||||
// just mirrors it so the box is disabled until the fields are complete.
|
||||
const requiredFields = Array.from(form.querySelectorAll('[data-security-required="1"]'));
|
||||
const readonly = requiredFields.length > 0 && requiredFields.every((field) => field.disabled);
|
||||
|
||||
const gateGroups = [];
|
||||
new Set(requiredFields.map((field) => field.closest('.security-step'))).forEach((step) => {
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
const doneBox = step.querySelector('input[type="checkbox"][name$="[done]"]');
|
||||
if (!doneBox) {
|
||||
return;
|
||||
}
|
||||
gateGroups.push({
|
||||
doneBox,
|
||||
hint: step.querySelector('[data-security-gate-hint]'),
|
||||
fields: requiredFields.filter((field) => field.closest('.security-step') === step),
|
||||
});
|
||||
});
|
||||
|
||||
const enforceGates = () => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
gateGroups.forEach(({ doneBox, hint, fields }) => {
|
||||
const met = fields.every((field) => field.value.trim() !== '');
|
||||
doneBox.disabled = !met;
|
||||
if (!met && doneBox.checked) {
|
||||
doneBox.checked = false;
|
||||
}
|
||||
if (hint) {
|
||||
hint.hidden = met;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const listenerController = new AbortController();
|
||||
|
||||
const refresh = () => {
|
||||
const boxes = doneToggles();
|
||||
const total = boxes.length;
|
||||
const done = boxes.filter((b) => b.checked).length;
|
||||
boxes.forEach((box) => {
|
||||
const row = box.closest('.security-step, .security-tech-item');
|
||||
row?.classList.toggle('is-done', box.checked);
|
||||
});
|
||||
if (fill && total > 0) {
|
||||
fill.style.width = `${Math.floor((done / total) * 100)}%`;
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement && target.type === 'checkbox' && target.name.endsWith('[done]')) {
|
||||
refresh();
|
||||
}
|
||||
}, { signal: listenerController.signal });
|
||||
|
||||
if (!readonly && gateGroups.length > 0) {
|
||||
const onFieldChange = (event) => {
|
||||
if (event.target instanceof HTMLElement && event.target.matches('[data-security-required="1"]')) {
|
||||
enforceGates();
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
form.addEventListener('input', onFieldChange, { signal: listenerController.signal });
|
||||
form.addEventListener('change', onFieldChange, { signal: listenerController.signal });
|
||||
}
|
||||
|
||||
enforceGates();
|
||||
refresh();
|
||||
|
||||
return {
|
||||
destroy: () => listenerController.abort(),
|
||||
};
|
||||
}
|
||||
194
modules/security/web/js/security-customer-domain-select.js
Normal file
194
modules/security/web/js/security-customer-domain-select.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Dynamic domain select for the security-check create wizard.
|
||||
*
|
||||
* Listens to the core [data-app-lookup] customer picker and loads the customer's
|
||||
* domains from the Security BC gateway. Falls back to manual entry when the
|
||||
* customer has no domains.
|
||||
*/
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
|
||||
const SELECTOR = '#security-domain-group[data-app-component="security-customer-domain-select"]';
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveGroup = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector(SELECTOR);
|
||||
};
|
||||
|
||||
export function initSecurityCustomerDomainSelect(root = document) {
|
||||
const group = resolveGroup(root);
|
||||
if (!group) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const scope = group.closest('form') || document;
|
||||
const lookupContainer = scope.querySelector('[data-app-lookup]');
|
||||
const domainSelect = group.querySelector('#security-domain');
|
||||
const domainNoInput = group.querySelector('#security-domain-no');
|
||||
const domainUrlInput = group.querySelector('#security-domain-url');
|
||||
const feedback = group.querySelector('#security-domain-feedback');
|
||||
const manualBlock = group.querySelector('#security-domain-manual');
|
||||
const manualNoInput = group.querySelector('#security-domain-manual-no');
|
||||
const manualUrlInput = group.querySelector('#security-domain-manual-url');
|
||||
if (!lookupContainer || !domainSelect) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const domainsUrl = group.dataset.domainsUrl || '';
|
||||
const textInitial = group.dataset.textInitial || '';
|
||||
const textLoading = group.dataset.textLoading || '';
|
||||
const textSelect = group.dataset.textSelect || '';
|
||||
const textError = group.dataset.textError || '';
|
||||
|
||||
const listenerController = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const clearOptions = () => {
|
||||
while (domainSelect.options.length > 0) {
|
||||
domainSelect.remove(0);
|
||||
}
|
||||
};
|
||||
|
||||
const addPlaceholder = (text) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = text;
|
||||
domainSelect.appendChild(opt);
|
||||
};
|
||||
|
||||
const setFeedback = (text, variant) => {
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
feedback.textContent = text;
|
||||
feedback.hidden = !text;
|
||||
feedback.dataset.variant = variant || '';
|
||||
};
|
||||
|
||||
const showManual = (show) => {
|
||||
if (!manualBlock) {
|
||||
return;
|
||||
}
|
||||
manualBlock.hidden = !show;
|
||||
domainSelect.closest('.app-wizard-domain-select-wrap')?.toggleAttribute('hidden', show);
|
||||
if (!show && manualNoInput) manualNoInput.value = '';
|
||||
if (!show && manualUrlInput) manualUrlInput.value = '';
|
||||
if (show) {
|
||||
if (domainNoInput) domainNoInput.value = '';
|
||||
if (domainUrlInput) domainUrlInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const resetDomain = () => {
|
||||
clearOptions();
|
||||
addPlaceholder(textInitial);
|
||||
domainSelect.disabled = true;
|
||||
if (domainNoInput) domainNoInput.value = '';
|
||||
if (domainUrlInput) domainUrlInput.value = '';
|
||||
setFeedback('', '');
|
||||
showManual(false);
|
||||
};
|
||||
|
||||
const loadDomains = async (debitorNo) => {
|
||||
clearOptions();
|
||||
addPlaceholder(textLoading);
|
||||
domainSelect.disabled = true;
|
||||
group.setAttribute('aria-busy', 'true');
|
||||
setFeedback('', '');
|
||||
|
||||
try {
|
||||
const data = await getJson(`${domainsUrl}?debitor_no=${encodeURIComponent(debitorNo)}`, {
|
||||
signal: requestController.signal,
|
||||
});
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
|
||||
if (items.length === 0) {
|
||||
domainSelect.disabled = true;
|
||||
showManual(true);
|
||||
return;
|
||||
}
|
||||
showManual(false);
|
||||
|
||||
items.forEach((item) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.value || '';
|
||||
opt.textContent = item.label || item.value || '';
|
||||
opt.dataset.url = item.url || '';
|
||||
domainSelect.appendChild(opt);
|
||||
});
|
||||
domainSelect.disabled = false;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.name === 'AbortError')) {
|
||||
clearOptions();
|
||||
addPlaceholder(textSelect);
|
||||
domainSelect.disabled = true;
|
||||
setFeedback(textError, 'error');
|
||||
}
|
||||
} finally {
|
||||
group.removeAttribute('aria-busy');
|
||||
}
|
||||
};
|
||||
|
||||
on(domainSelect, 'change', () => {
|
||||
const selected = domainSelect.options[domainSelect.selectedIndex];
|
||||
if (domainNoInput) domainNoInput.value = selected?.value || '';
|
||||
if (domainUrlInput) domainUrlInput.value = selected?.dataset.url || '';
|
||||
});
|
||||
|
||||
if (manualNoInput) {
|
||||
on(manualNoInput, 'input', () => {
|
||||
if (domainNoInput) domainNoInput.value = manualNoInput.value.trim();
|
||||
});
|
||||
}
|
||||
if (manualUrlInput) {
|
||||
on(manualUrlInput, 'input', () => {
|
||||
if (domainUrlInput) domainUrlInput.value = manualUrlInput.value.trim();
|
||||
});
|
||||
}
|
||||
|
||||
on(lookupContainer, 'lookup:select', (event) => {
|
||||
const debitorNo = event.detail?.value || '';
|
||||
if (debitorNo) {
|
||||
void loadDomains(debitorNo);
|
||||
} else {
|
||||
resetDomain();
|
||||
}
|
||||
});
|
||||
|
||||
on(lookupContainer, 'lookup:clear', () => {
|
||||
resetDomain();
|
||||
});
|
||||
|
||||
const initialDebitor = group.dataset.initialDebitor || '';
|
||||
const initialDomain = group.dataset.initialDomain || '';
|
||||
if (initialDebitor) {
|
||||
void loadDomains(initialDebitor).then(() => {
|
||||
if (initialDomain && !domainSelect.disabled) {
|
||||
domainSelect.value = initialDomain;
|
||||
domainSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
} else if (domainNoInput && domainNoInput.value && manualNoInput) {
|
||||
manualNoInput.value = domainNoInput.value;
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
requestController.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
80
modules/security/web/js/security-settings.js
Normal file
80
modules/security/web/js/security-settings.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Security settings page — toggles the auth-mode sections and runs the
|
||||
* BC connection test.
|
||||
*/
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { getJson } from '/js/core/app-http.js';
|
||||
|
||||
const SELECTOR = '[data-app-component="security-settings"]';
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveRoot = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector(SELECTOR);
|
||||
};
|
||||
|
||||
export function initSecuritySettings(root = document) {
|
||||
const form = resolveRoot(root);
|
||||
if (!form) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const section = form.closest('section') || document;
|
||||
const listenerController = new AbortController();
|
||||
const modeSelect = form.querySelector('[data-security-auth-mode]');
|
||||
const authSections = Array.from(form.querySelectorAll('[data-security-auth-section]'));
|
||||
|
||||
const applyMode = () => {
|
||||
const mode = modeSelect ? modeSelect.value : 'basic';
|
||||
authSections.forEach((el) => {
|
||||
el.toggleAttribute('hidden', el.dataset.securityAuthSection !== mode);
|
||||
});
|
||||
};
|
||||
|
||||
if (modeSelect) {
|
||||
modeSelect.addEventListener('change', applyMode, { signal: listenerController.signal });
|
||||
applyMode();
|
||||
}
|
||||
|
||||
const testButton = section.querySelector('[data-security-test-connection]');
|
||||
const result = section.querySelector('[data-security-test-result]');
|
||||
if (testButton) {
|
||||
testButton.addEventListener('click', async () => {
|
||||
const url = testButton.dataset.url || '';
|
||||
if (url === '') {
|
||||
return;
|
||||
}
|
||||
testButton.setAttribute('aria-busy', 'true');
|
||||
testButton.disabled = true;
|
||||
if (result) {
|
||||
result.textContent = testButton.dataset.textTesting || 'Testing...';
|
||||
result.dataset.variant = '';
|
||||
}
|
||||
try {
|
||||
const data = await getJson(url);
|
||||
if (result) {
|
||||
const ok = data && data.ok === true;
|
||||
result.textContent = ok
|
||||
? (testButton.dataset.textOk || 'Connection successful')
|
||||
: `${testButton.dataset.textFail || 'Connection failed'}: ${(data && data.error) || ''}`;
|
||||
result.dataset.variant = ok ? 'success' : 'error';
|
||||
}
|
||||
} catch {
|
||||
if (result) {
|
||||
result.textContent = testButton.dataset.textFail || 'Connection failed';
|
||||
result.dataset.variant = 'error';
|
||||
}
|
||||
} finally {
|
||||
testButton.removeAttribute('aria-busy');
|
||||
testButton.disabled = false;
|
||||
}
|
||||
}, { signal: listenerController.signal });
|
||||
}
|
||||
|
||||
return {
|
||||
destroy: () => listenerController.abort(),
|
||||
};
|
||||
}
|
||||
187
modules/security/web/js/security-template-schema-editor.js
Normal file
187
modules/security/web/js/security-template-schema-editor.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Security check template schema editor.
|
||||
*
|
||||
* Edits the per-product technical checklist as an ordered list of items:
|
||||
* - section: a grouping heading (label only)
|
||||
* - check: a tracked checkpoint (label + optional hint + optional Markdown guidance)
|
||||
*
|
||||
* Serializes the items to the hidden #security-schema-json input on every change
|
||||
* and on form submit. Item keys are (re)generated server-side on save.
|
||||
*/
|
||||
import { resolveHost } from '/js/core/app-dom.js';
|
||||
import { escapeHtml } from '/js/pages/app-list-utils.js';
|
||||
|
||||
const SELECTOR = '#security-schema-editor[data-app-component="security-template-schema-editor"]';
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
const resolveEditor = (root) => {
|
||||
const host = resolveHost(root);
|
||||
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
|
||||
return host;
|
||||
}
|
||||
return host.querySelector(SELECTOR);
|
||||
};
|
||||
|
||||
export function initSecurityTemplateSchemaEditor(root = document) {
|
||||
const editor = resolveEditor(root);
|
||||
if (!editor) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const form = editor.closest('form');
|
||||
const hidden = form?.querySelector('#security-schema-json');
|
||||
if (!form || !hidden) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const labels = {
|
||||
section: editor.dataset.labelSection || 'Section',
|
||||
check: editor.dataset.labelCheck || 'Check item',
|
||||
addSection: editor.dataset.labelAddSection || 'Add section',
|
||||
addCheck: editor.dataset.labelAddCheck || 'Add check item',
|
||||
remove: editor.dataset.labelRemove || 'Remove',
|
||||
moveUp: editor.dataset.labelMoveUp || 'Move up',
|
||||
moveDown: editor.dataset.labelMoveDown || 'Move down',
|
||||
phSection: editor.dataset.placeholderSection || 'Section title',
|
||||
phCheck: editor.dataset.placeholderCheck || 'Check item label',
|
||||
phHint: editor.dataset.placeholderHint || 'Hint (optional)',
|
||||
phMarkdown: editor.dataset.placeholderMarkdown || 'Markdown (optional)',
|
||||
empty: editor.dataset.emptyText || 'No checklist items yet.',
|
||||
};
|
||||
|
||||
const listenerController = new AbortController();
|
||||
|
||||
/** @type {{type:string,label:string,hint:string,markdown:string}[]} */
|
||||
let items = [];
|
||||
try {
|
||||
const initial = JSON.parse(document.getElementById('security-schema-initial')?.textContent || '[]');
|
||||
if (Array.isArray(initial)) {
|
||||
items = initial.map((it) => ({
|
||||
type: it && it.type === 'section' ? 'section' : 'check',
|
||||
label: String((it && it.label) || ''),
|
||||
hint: String((it && it.hint) || ''),
|
||||
markdown: String((it && it.markdown) || ''),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
items = [];
|
||||
}
|
||||
|
||||
const serialize = () => {
|
||||
hidden.value = JSON.stringify(items.map((it) => (
|
||||
it.type === 'section'
|
||||
? { type: 'section', label: it.label }
|
||||
: { type: 'check', label: it.label, hint: it.hint, markdown: it.markdown }
|
||||
)));
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'security-schema-items';
|
||||
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'app-muted';
|
||||
empty.textContent = labels.empty;
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `security-schema-item security-schema-item-${item.type}`;
|
||||
row.dataset.index = String(index);
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'security-schema-item-row';
|
||||
|
||||
const labelInput = document.createElement('input');
|
||||
labelInput.type = 'text';
|
||||
labelInput.className = 'security-schema-label';
|
||||
labelInput.value = item.label;
|
||||
labelInput.placeholder = item.type === 'section' ? labels.phSection : labels.phCheck;
|
||||
labelInput.addEventListener('input', () => { items[index].label = labelInput.value; serialize(); }, { signal: listenerController.signal });
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'security-schema-type badge';
|
||||
typeBadge.dataset.variant = item.type === 'section' ? 'neutral' : 'info';
|
||||
typeBadge.textContent = item.type === 'section' ? labels.section : labels.check;
|
||||
|
||||
topRow.appendChild(typeBadge);
|
||||
topRow.appendChild(labelInput);
|
||||
|
||||
if (item.type === 'check') {
|
||||
const hintInput = document.createElement('input');
|
||||
hintInput.type = 'text';
|
||||
hintInput.className = 'security-schema-hint';
|
||||
hintInput.value = item.hint;
|
||||
hintInput.placeholder = labels.phHint;
|
||||
hintInput.addEventListener('input', () => { items[index].hint = hintInput.value; serialize(); }, { signal: listenerController.signal });
|
||||
topRow.appendChild(hintInput);
|
||||
}
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'security-schema-controls';
|
||||
controls.innerHTML = `
|
||||
<button type="button" class="app-icon-button" data-action="up" data-tooltip="${escapeHtml(labels.moveUp)}" aria-label="${escapeHtml(labels.moveUp)}"><i class="bi bi-arrow-up"></i></button>
|
||||
<button type="button" class="app-icon-button" data-action="down" data-tooltip="${escapeHtml(labels.moveDown)}" aria-label="${escapeHtml(labels.moveDown)}"><i class="bi bi-arrow-down"></i></button>
|
||||
<button type="button" class="app-icon-button" data-action="remove" data-tooltip="${escapeHtml(labels.remove)}" aria-label="${escapeHtml(labels.remove)}"><i class="bi bi-x-lg"></i></button>
|
||||
`;
|
||||
controls.querySelectorAll('button[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'remove') {
|
||||
items.splice(index, 1);
|
||||
} else if (action === 'up' && index > 0) {
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
} else if (action === 'down' && index < items.length - 1) {
|
||||
[items[index + 1], items[index]] = [items[index], items[index + 1]];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
serialize();
|
||||
render();
|
||||
}, { signal: listenerController.signal });
|
||||
});
|
||||
topRow.appendChild(controls);
|
||||
row.appendChild(topRow);
|
||||
|
||||
if (item.type === 'check') {
|
||||
const markdownInput = document.createElement('textarea');
|
||||
markdownInput.className = 'security-schema-markdown';
|
||||
markdownInput.rows = 2;
|
||||
markdownInput.value = item.markdown;
|
||||
markdownInput.placeholder = labels.phMarkdown;
|
||||
markdownInput.addEventListener('input', () => { items[index].markdown = markdownInput.value; serialize(); }, { signal: listenerController.signal });
|
||||
row.appendChild(markdownInput);
|
||||
}
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'security-schema-toolbar';
|
||||
const addSection = document.createElement('button');
|
||||
addSection.type = 'button';
|
||||
addSection.className = 'secondary outline small';
|
||||
addSection.innerHTML = `<i class="bi bi-type-h3"></i> ${escapeHtml(labels.addSection)}`;
|
||||
addSection.addEventListener('click', () => { items.push({ type: 'section', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
|
||||
const addCheck = document.createElement('button');
|
||||
addCheck.type = 'button';
|
||||
addCheck.className = 'secondary outline small';
|
||||
addCheck.innerHTML = `<i class="bi bi-check2-square"></i> ${escapeHtml(labels.addCheck)}`;
|
||||
addCheck.addEventListener('click', () => { items.push({ type: 'check', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
|
||||
toolbar.appendChild(addCheck);
|
||||
toolbar.appendChild(addSection);
|
||||
|
||||
editor.replaceChildren(list, toolbar);
|
||||
};
|
||||
|
||||
form.addEventListener('submit', serialize, { signal: listenerController.signal });
|
||||
|
||||
serialize();
|
||||
render();
|
||||
|
||||
return {
|
||||
destroy: () => listenerController.abort(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user