forked from fa/breadcrumb-the-shire
agent foundation
This commit is contained in:
14
agent-system/README.md
Normal file
14
agent-system/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Agent System
|
||||
|
||||
This folder contains the technical assets for the multi-role workflow.
|
||||
|
||||
Structure:
|
||||
- `contracts/`: JSON schemas for role outputs
|
||||
- `templates/`: starter JSON payloads
|
||||
- `prompts/`: role-specific prompt baselines
|
||||
- `checks/`: guard catalog, quality gates, and review checklists
|
||||
- `runs/`: optional runtime artifacts (can stay untracked)
|
||||
|
||||
References:
|
||||
- Planner standard: `tools/codex-skills/starterkit-planner/SKILL.md`
|
||||
- Guardrails: `tools/codex-skills/core-guardrails/SKILL.md`
|
||||
25
agent-system/checks/acceptance-checklist.md
Normal file
25
agent-system/checks/acceptance-checklist.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Acceptance Checklist
|
||||
|
||||
Use this checklist for the acceptance reviewer.
|
||||
|
||||
## Scope
|
||||
- all in-scope items from `plan.json` are implemented
|
||||
- out-of-scope items are not silently introduced
|
||||
|
||||
## Behavior
|
||||
- each `SC-*` success criterion from `plan.json` is verifiably met
|
||||
- negative and edge paths behave as defined
|
||||
|
||||
## Regressions
|
||||
- existing flows still work after change
|
||||
- no critical UX break on key user paths
|
||||
|
||||
## Tests
|
||||
- every test listed in `plan.json tests[]` is implemented and passes (QG-001 green)
|
||||
- new Service/Gateway logic has at least one test covering the happy path and one failure/edge case (GR-TEST-001)
|
||||
- no new untestable code patterns introduced (GR-TEST-002)
|
||||
|
||||
## Evidence
|
||||
- each criterion has explicit pass/fail evidence
|
||||
- each criterion check references the exact `SC-*` ID
|
||||
- fail findings include exact missing behavior
|
||||
272
agent-system/checks/guard-catalog.json
Normal file
272
agent-system/checks/guard-catalog.json
Normal file
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"source_skill": "tools/codex-skills/core-guardrails/SKILL.md",
|
||||
"guards": [
|
||||
{
|
||||
"id": "GR-CORE-001",
|
||||
"area": "core",
|
||||
"title": "Layering boundaries",
|
||||
"requirement": "MUST keep pages orchestration-only and business logic in services.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-002",
|
||||
"area": "core",
|
||||
"title": "No direct service instantiation in pages",
|
||||
"requirement": "MUST NOT instantiate Service, Gateway or Repository directly in pages.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-003",
|
||||
"area": "core",
|
||||
"title": "Request input contract",
|
||||
"requirement": "MUST read input via requestInput in pages and avoid superglobals for request data.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-004",
|
||||
"area": "core",
|
||||
"title": "API json body contract",
|
||||
"requirement": "MUST NOT use ApiResponse::readJsonBody in pages/api/v1.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-005",
|
||||
"area": "security",
|
||||
"title": "Server-side authz",
|
||||
"requirement": "MUST enforce authorization server-side.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-006",
|
||||
"area": "security",
|
||||
"title": "Server-side tenant scope",
|
||||
"requirement": "MUST enforce tenant scope server-side.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-007",
|
||||
"area": "api",
|
||||
"title": "OpenAPI sync",
|
||||
"requirement": "MUST update OpenAPI in same merge when API changes.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-001",
|
||||
"area": "ui",
|
||||
"title": "List initialization standard",
|
||||
"requirement": "MUST initialize lists via initStandardListPage.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-002",
|
||||
"area": "ui",
|
||||
"title": "No legacy filter toggle markup",
|
||||
"requirement": "MUST NOT introduce legacy filter toggle attributes.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-003",
|
||||
"area": "ui",
|
||||
"title": "Row interaction standard",
|
||||
"requirement": "MUST preserve row action column plus enter and dblclick fallback behavior.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-004",
|
||||
"area": "ui",
|
||||
"title": "Page size standard",
|
||||
"requirement": "MUST preserve page size options and URL/localStorage/default precedence.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-005",
|
||||
"area": "ui",
|
||||
"title": "Detail page standard",
|
||||
"requirement": "MUST use standard detail page contracts for dirty-state and actions.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-006",
|
||||
"area": "ui",
|
||||
"title": "No inline confirm",
|
||||
"requirement": "MUST NOT use inline confirm handlers on standardized detail flows.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-007",
|
||||
"area": "ui",
|
||||
"title": "Shared partial usage",
|
||||
"requirement": "MUST use shared list and detail partials for standardized UI blocks.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-016",
|
||||
"area": "ui",
|
||||
"title": "Template structure discipline for new features",
|
||||
"requirement": "New pages MUST use the established layout system (templates/default.phtml or a named layout variant). UI blocks that appear in two or more pages MUST be extracted as a partial under templates/partials/ rather than duplicated inline. New partials MUST follow the naming convention app-{context}-{purpose}.phtml. Page-specific rendering belongs in pages/*.phtml, not in templates/. MUST NOT create ad-hoc layout HTML inside pages that bypasses the layout system.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-008",
|
||||
"area": "ui",
|
||||
"title": "Template RBAC contract",
|
||||
"requirement": "MUST use viewAuth in templates and avoid can calls.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-009",
|
||||
"area": "ui",
|
||||
"title": "No a11y regression",
|
||||
"requirement": "MUST NOT introduce regressions in accessibility basics (labels, roles, keyboard focus) on touched flows.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-008",
|
||||
"area": "core",
|
||||
"title": "No direct superglobal access in pages",
|
||||
"requirement": "MUST NOT use $_SESSION, $_SERVER, $_COOKIE, $_ENV directly in pages; use framework abstractions (app(SessionStoreInterface::class), RequestContext, requestInput()) instead.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-001",
|
||||
"area": "security",
|
||||
"title": "CSRF verification on POST endpoints",
|
||||
"requirement": "MUST verify CSRF token on every POST endpoint in pages before processing the request body.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-002",
|
||||
"area": "security",
|
||||
"title": "No unredacted PII or secrets in logs and audit metadata",
|
||||
"requirement": "MUST NOT write PII (email, names, UUIDs) or secrets (tokens, passwords) to logs or audit metadata without prior redaction.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-003",
|
||||
"area": "security",
|
||||
"title": "SQL via Repository with prepared statements only",
|
||||
"requirement": "MUST NOT build SQL strings with user-controlled input; all queries MUST go through Repository classes using prepared statements.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-LANG-001",
|
||||
"area": "lang",
|
||||
"title": "PSR-4 namespace alignment",
|
||||
"requirement": "MUST align PHP namespace with directory path (MintyPHP\\ → lib/, MintyPHP\\Tests\\ → tests/); violations are caught by QG-003.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-LANG-002",
|
||||
"area": "lang",
|
||||
"title": "PHP style compliance",
|
||||
"requirement": "MUST pass composer cs:check without diff (php-cs-fixer dry-run); fix with composer cs:fix before merge.",
|
||||
"source": "tools/codex-skills/starterkit-php-style-ci/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-TEST-001",
|
||||
"area": "test",
|
||||
"title": "Tests ship with new logic",
|
||||
"requirement": "New or changed business logic in lib/Service/ MUST be accompanied by PHPUnit tests in the same merge covering the happy path and at least one failure or edge case. No new public method in a Service or Gateway without a test.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-TEST-002",
|
||||
"area": "test",
|
||||
"title": "Code must be unit-testable",
|
||||
"requirement": "New code MUST use constructor injection for all dependencies. MUST NOT introduce static side-effects in constructors, hidden global state, or direct DB/HTTP calls outside Repository/Gateway abstractions — patterns that make unit testing without mocks impossible.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-010",
|
||||
"area": "core",
|
||||
"title": "DB schema changes for existing installs must be idempotent SQL scripts in db/updates/",
|
||||
"requirement": "Any schema or data change for existing installations MUST be shipped as an idempotent SQL script in db/updates/ (e.g. using IF NOT EXISTS, ON DUPLICATE KEY, or guarded ALTER TABLE). MUST NOT modify db/init/init.sql only — existing containers will not re-run init.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-011",
|
||||
"area": "core",
|
||||
"title": "New settings belong in the DB settings table, not in config files",
|
||||
"requirement": "New application settings MUST be stored in the settings DB table as the single source of truth. config/settings.php is a partial hot-path cache only and MUST NOT be used as primary storage for new settings. Security, integration, and feature-flag settings MUST go through the settings gateway layer.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-012",
|
||||
"area": "core",
|
||||
"title": "POST-Redirect-GET after successful mutating actions",
|
||||
"requirement": "Actions that successfully mutate state (create, update, delete) MUST respond with a redirect (PRG pattern) rather than rendering a response directly. This prevents double-submit on browser reload. Flash messages MUST be set before the redirect via Flash::set().",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-CORE-009",
|
||||
"area": "core",
|
||||
"title": "Repository list queries must use RepoQuery::sanitizeLimitOffset",
|
||||
"requirement": "New Repository methods that return lists MUST use RepoQuery::sanitizeLimitOffset() to enforce LIMIT and OFFSET. Unbounded SELECT queries without LIMIT on potentially large tables are forbidden. The default maxLimit of 100 MUST NOT be raised without explicit justification reviewed and approved.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-006",
|
||||
"area": "security",
|
||||
"title": "User-uploaded files stored in storage/, never in web/",
|
||||
"requirement": "Files uploaded by users MUST be stored under storage/ (e.g. storage/users/{uuid}/, storage/tenants/{uuid}/, storage/imports/) and MUST NOT be written to web/ or any other publicly served directory. Storage paths MUST use non-guessable identifiers (UUID v4 or equivalent) to prevent enumeration. Files MUST be served through a PHP controller that enforces authorization, not directly by the web server. MUST validate file type (MIME + extension allowlist) before storing.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-005",
|
||||
"area": "security",
|
||||
"title": "Encryption only via lib/Support/Crypto.php",
|
||||
"requirement": "Any encryption or decryption of sensitive data MUST use the Crypto class (lib/Support/Crypto.php) which provides AES-256-GCM via APP_CRYPTO_KEY. MUST NOT use raw openssl_encrypt/openssl_decrypt, base64 encoding as a substitute for encryption, or any other hand-rolled crypto primitives.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-core.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-SEC-004",
|
||||
"area": "security",
|
||||
"title": "No external CDN or remote asset loading",
|
||||
"requirement": "MUST NOT load JS, CSS, fonts, or any other assets from external CDNs (Cloudflare, jsDelivr, unpkg, Google Fonts, etc.) or remote URLs in templates, pages, or layout files. If a third-party library is required it MUST be vendored locally in the repository (e.g. web/js/vendor/ or web/css/vendor/) and served via assetVersion(). No <script src='https://...'>, <link href='https://...'>, @import url('https://...'), or fetch() calls to external asset hosts.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-014",
|
||||
"area": "ui",
|
||||
"title": "UI state completeness for new views and components",
|
||||
"requirement": "New user-facing views or components that display data or trigger actions MUST handle all four states: loading (progress indicator while data is fetched), empty (explicit message when no data exists), error (user-visible error feedback when an action or data load fails), and success (confirmation after a completed action). All four states MUST be defined in the plan before implementation starts. Applies to new views and components; not required for small, isolated fixes to existing UI.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-013",
|
||||
"area": "ui",
|
||||
"title": "A11y by design: new interactive components built accessible from the start",
|
||||
"requirement": "New interactive UI components (buttons, links, forms, modals, dropdowns, custom controls) MUST use semantic HTML elements. MUST be fully keyboard-operable (Tab order, Enter/Space to activate, Escape to close overlays, no keyboard trap). MUST use ARIA attributes only where semantic HTML is insufficient — no ARIA-spam. MUST meet WCAG 2.1 AA color contrast for any newly introduced text or icon elements. These requirements apply at creation time, not as a post-hoc retrofit.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-015",
|
||||
"area": "ui",
|
||||
"title": "i18n key completeness: all language files updated in the same merge",
|
||||
"requirement": "Every new translation key added via t('key') MUST be present in ALL language files (i18n/default_de.json and i18n/default_en.json) in the same merge. A key present in one file but missing in another causes visible fallback output. MUST NOT leave placeholder or empty string values without a TODO comment.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-010",
|
||||
"area": "ui",
|
||||
"title": "i18n: all visible text must use t()",
|
||||
"requirement": "MUST wrap all user-visible strings in phtml views with t('key'). MUST NOT hardcode display strings in templates or partials.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-011",
|
||||
"area": "ui",
|
||||
"title": "JS reuse-first: prefer existing exports over new functions",
|
||||
"requirement": "MUST check web/js/core/ and web/js/components/ for existing exported functions before adding a new one. A new export is only justified when no existing function covers the use-case after review. MUST NOT duplicate logic already present in app-dom.js, app-grid-factory.js, app-detail-page-factory.js, or other core modules.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
},
|
||||
{
|
||||
"id": "GR-UI-012",
|
||||
"area": "ui",
|
||||
"title": "CSS reuse-first: prefer existing classes over new rules",
|
||||
"requirement": "MUST check existing CSS layers (base, components, layout, pages) for suitable classes before writing new rules. MUST use existing app-* component classes as the basis and only add additive overrides. MUST NOT duplicate declarations already defined in shared component or layout files.",
|
||||
"source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
65
agent-system/checks/guard-checklist.md
Normal file
65
agent-system/checks/guard-checklist.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Guard Checklist
|
||||
|
||||
Use this checklist for the guard reviewer.
|
||||
Canonical guard IDs live in `agent-system/checks/guard-catalog.json`.
|
||||
|
||||
## Architecture
|
||||
- `GR-CORE-001` no boundary violations against project layering
|
||||
- `GR-CORE-002` no direct bypass of shared factories/services where prohibited
|
||||
- `GR-CORE-003` request input contract is respected (requestInput(), no $_POST/$_GET/$_FILES/REQUEST_METHOD)
|
||||
- `GR-CORE-004` API json body contract is respected
|
||||
- `GR-CORE-008` no direct superglobal access in pages ($_SESSION, $_SERVER, $_COOKIE, $_ENV)
|
||||
- `GR-CORE-009` new Repository list methods use RepoQuery::sanitizeLimitOffset(); no unbounded SELECTs on large tables; maxLimit 100 not raised without justification
|
||||
- `GR-CORE-010` DB schema changes for existing installs shipped as idempotent SQL scripts in db/updates/; db/init/init.sql alone is not sufficient
|
||||
- `GR-CORE-011` new settings stored in DB settings table via settings gateway; config/settings.php is hot-path cache only, not primary storage
|
||||
- `GR-CORE-012` successful mutating POSTs respond with redirect (PRG); flash messages set before redirect via Flash::set()
|
||||
|
||||
## Security
|
||||
- `GR-CORE-005` auth and permission checks stay intact
|
||||
- `GR-CORE-006` tenant-scope checks stay intact
|
||||
- `GR-SEC-001` CSRF token verified on every POST endpoint before request body processing
|
||||
- `GR-SEC-002` no unredacted PII or secrets in logs and audit metadata
|
||||
- `GR-SEC-003` all SQL goes through Repository with prepared statements; no string-interpolated queries
|
||||
- `GR-SEC-004` no external CDN or remote asset loading; all JS/CSS/fonts must be vendored locally and served via assetVersion()
|
||||
- `GR-SEC-005` encryption only via lib/Support/Crypto.php (AES-256-GCM); no raw openssl_encrypt, no base64-as-encryption, no hand-rolled crypto
|
||||
- `GR-SEC-006` user-uploaded files stored under storage/{type}/{uuid}/ only; never written to web/; served via PHP controller with authz check; MIME + extension validated before storage
|
||||
|
||||
## Frontend and UX
|
||||
- `GR-UI-001` list initialization standard is preserved
|
||||
- `GR-UI-002` no legacy filter toggle markup introduced
|
||||
- `GR-UI-003` row interaction standard is preserved where applicable
|
||||
- `GR-UI-004` page size standard is preserved where applicable
|
||||
- `GR-UI-005` detail page standard is preserved where applicable
|
||||
- `GR-UI-006` no inline confirm hacks
|
||||
- `GR-UI-007` shared partial usage remains consistent (existing partials reused where applicable)
|
||||
- `GR-UI-016` template structure discipline: new pages use established layout system; blocks reused in ≥2 pages extracted as partial in templates/partials/ with naming app-{context}-{purpose}.phtml; no ad-hoc layout HTML in pages that bypasses the layout system
|
||||
- `GR-UI-008` template RBAC contract remains consistent
|
||||
- `GR-UI-009` no regression in a11y basics (labels, roles, keyboard focus) for touched flows
|
||||
- `GR-UI-013` a11y by design: new interactive components use semantic HTML, are keyboard-operable (Tab/Enter/Escape), use ARIA only where necessary, meet WCAG 2.1 AA contrast
|
||||
- `GR-UI-014` UI state completeness: new views/components define and implement loading, empty, error, and success states before implementation starts
|
||||
- `GR-UI-010` all user-visible strings in phtml views wrapped in t(); no hardcoded display strings
|
||||
- `GR-UI-015` new t() keys present in all language files (de + en) in the same merge; no key missing in any language file
|
||||
- `GR-UI-011` JS reuse-first: existing exports in web/js/core/ and web/js/components/ checked before adding a new function; no logic duplication from core modules
|
||||
- `GR-UI-012` CSS reuse-first: existing app-* classes and layer rules checked before writing new declarations; only additive overrides on top of shared components
|
||||
|
||||
## Testing
|
||||
- `GR-TEST-001` new or changed Service/Gateway logic has accompanying PHPUnit tests (happy path + at least one failure/edge case) in the same merge
|
||||
- `GR-TEST-002` new code uses constructor injection; no static side-effects, hidden global state, or untestable DB/HTTP calls outside Repository/Gateway abstractions
|
||||
|
||||
## Language and Style
|
||||
- `GR-LANG-001` PSR-4 namespace/directory alignment is intact (verified by QG-003)
|
||||
- `GR-LANG-002` PHP style passes composer cs:check without diff for touched files
|
||||
|
||||
## Quality Gates
|
||||
- `QG-001` to `QG-003` are run unless explicitly out of scope
|
||||
- `QG-004` fast structural checks reported when relevant
|
||||
- `QG-005` JS smoke performed for JS-touching changes; no new unhandled runtime errors
|
||||
- `QG-006` PHP style check run for any PHP-touching changes
|
||||
- `QG-007` composer-unused run periodically; any newly unused package flagged to reviewer
|
||||
|
||||
References:
|
||||
- `tools/codex-skills/core-guardrails/references/boundaries-core.md`
|
||||
- `tools/codex-skills/core-guardrails/references/boundaries-ui.md`
|
||||
- `tools/codex-skills/core-guardrails/references/quality-gates.md`
|
||||
- `tools/codex-skills/starterkit-grid-standards/SKILL.md`
|
||||
- `tools/codex-skills/starterkit-php-style-ci/SKILL.md`
|
||||
49
agent-system/checks/quality-gates.json
Normal file
49
agent-system/checks/quality-gates.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"source": "tools/codex-skills/core-guardrails/references/quality-gates.md",
|
||||
"gates": [
|
||||
{
|
||||
"id": "QG-001",
|
||||
"type": "mandatory",
|
||||
"name": "PHPUnit Full",
|
||||
"command": "docker compose exec php vendor/bin/phpunit"
|
||||
},
|
||||
{
|
||||
"id": "QG-002",
|
||||
"type": "mandatory",
|
||||
"name": "PHPStan",
|
||||
"command": "docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress"
|
||||
},
|
||||
{
|
||||
"id": "QG-003",
|
||||
"type": "mandatory",
|
||||
"name": "Architecture Core Contract",
|
||||
"command": "docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php"
|
||||
},
|
||||
{
|
||||
"id": "QG-004",
|
||||
"type": "fast",
|
||||
"name": "Structural rg checks",
|
||||
"command": "(rg 'new\\s+[^\\s(]+Factory\\(' lib/Service || true) | wc -l\n(rg --glob '!**/*Factory.php' 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' lib/Service || true) | wc -l\n(rg \"\\b(accessServicesFactory|directoryServicesFactory|userServicesFactory|authServicesFactory|tenantServicesFactory|settingServicesFactory|userRepositoryFactory|authRepositoryFactory|authGatewayFactory)\\(\" pages lib templates web || true) | wc -l\n(rg -n 'new\\s+[^\\s(]+(Service|Gateway|Repository)\\(' pages -g '*.php' || true) | wc -l\n(rg -n '\\bDB::' pages -g '*.php' || true) | wc -l\n(rg -n 'app\\([^\\n]*Factory::class\\)->create' pages/admin -g '*.php' || true) | wc -l\n(rg -n 'Factory::class' pages/admin -g '*.php' || true) | wc -l\n(rg -n 'ApiResponse::readJsonBody\\(' pages/api/v1 -g '*.php' || true) | wc -l"
|
||||
},
|
||||
{
|
||||
"id": "QG-005",
|
||||
"type": "manual",
|
||||
"name": "JS smoke",
|
||||
"command": "Browser smoke test with DevTools console; expect no new JS errors."
|
||||
},
|
||||
{
|
||||
"id": "QG-006",
|
||||
"type": "fast",
|
||||
"name": "PHP Style Check",
|
||||
"command": "docker compose exec php composer cs:check"
|
||||
},
|
||||
{
|
||||
"id": "QG-007",
|
||||
"type": "periodic",
|
||||
"name": "Unused Composer packages",
|
||||
"command": "docker compose exec php vendor/bin/composer-unused",
|
||||
"note": "Run periodically (not per-merge). Flag any newly unused package to the reviewer."
|
||||
}
|
||||
]
|
||||
}
|
||||
97
agent-system/contracts/executor.schema.json
Normal file
97
agent-system/contracts/executor.schema.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Executor Output",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"task_id",
|
||||
"plan_ref",
|
||||
"status",
|
||||
"changed_files",
|
||||
"guard_evidence",
|
||||
"commands",
|
||||
"quality_gate_results",
|
||||
"test_results",
|
||||
"open_items"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "minLength": 1 },
|
||||
"plan_ref": { "type": "string", "minLength": 1 },
|
||||
"status": { "type": "string", "enum": ["done", "blocked"] },
|
||||
"changed_files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["path", "summary"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "minLength": 1 },
|
||||
"summary": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"guard_evidence": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["guard_id", "status", "evidence"],
|
||||
"properties": {
|
||||
"guard_id": {
|
||||
"type": "string",
|
||||
"pattern": "^GR-[A-Z]+-[0-9]{3}$"
|
||||
},
|
||||
"status": { "type": "string", "enum": ["pass", "fail", "n/a"] },
|
||||
"evidence": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["cmd", "result"],
|
||||
"properties": {
|
||||
"cmd": { "type": "string", "minLength": 1 },
|
||||
"result": { "type": "string", "enum": ["pass", "fail", "skipped"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"quality_gate_results": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["gate_id", "result"],
|
||||
"properties": {
|
||||
"gate_id": {
|
||||
"type": "string",
|
||||
"pattern": "^QG-[0-9]{3}$"
|
||||
},
|
||||
"result": { "type": "string", "enum": ["pass", "fail", "skipped"] },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"test_results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "result"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"result": { "type": "string", "enum": ["pass", "fail", "skipped"] },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"open_items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
105
agent-system/contracts/finalizer.schema.json
Normal file
105
agent-system/contracts/finalizer.schema.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Finalizer Output",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"task_id",
|
||||
"ready_to_finalize",
|
||||
"guard_review",
|
||||
"acceptance_review",
|
||||
"ci_status",
|
||||
"final_action"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "minLength": 1 },
|
||||
"ready_to_finalize": { "type": "boolean" },
|
||||
"guard_review": { "type": "string", "enum": ["pass", "fail"] },
|
||||
"acceptance_review": { "type": "string", "enum": ["pass", "fail"] },
|
||||
"ci_status": { "type": "string", "enum": ["pass", "fail", "unknown"] },
|
||||
"final_action": { "type": "string", "enum": ["commit", "merge", "hold"] },
|
||||
"hold_reason": { "type": "string", "minLength": 1 },
|
||||
"commit_message": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "final_action": { "const": "hold" } },
|
||||
"required": ["final_action"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["hold_reason"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"final_action": { "enum": ["commit", "merge"] }
|
||||
},
|
||||
"required": ["final_action"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"ready_to_finalize": { "const": true },
|
||||
"guard_review": { "const": "pass" },
|
||||
"acceptance_review": { "const": "pass" },
|
||||
"ci_status": { "const": "pass" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "ci_status": { "const": "fail" } },
|
||||
"required": ["ci_status"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "final_action": { "enum": ["hold"] } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"guard_review": { "const": "fail" }
|
||||
},
|
||||
"required": ["guard_review"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "final_action": { "enum": ["hold"] } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"acceptance_review": { "const": "fail" }
|
||||
},
|
||||
"required": ["acceptance_review"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "final_action": { "enum": ["hold"] } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"ci_status": { "const": "unknown" }
|
||||
},
|
||||
"required": ["ci_status"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "final_action": { "enum": ["hold"] } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"ready_to_finalize": { "const": false }
|
||||
},
|
||||
"required": ["ready_to_finalize"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "final_action": { "enum": ["hold"] } }
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
143
agent-system/contracts/planner.schema.json
Normal file
143
agent-system/contracts/planner.schema.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Planner Output",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"task_id",
|
||||
"summary",
|
||||
"scope",
|
||||
"guardrails",
|
||||
"success_criteria",
|
||||
"implementation_steps",
|
||||
"tests",
|
||||
"acceptance_checks",
|
||||
"risks"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "minLength": 1 },
|
||||
"summary": { "type": "string", "minLength": 1 },
|
||||
"assumptions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"required": ["in", "out"],
|
||||
"properties": {
|
||||
"in": { "type": "array", "items": { "type": "string" } },
|
||||
"out": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"guardrails": {
|
||||
"type": "object",
|
||||
"required": ["required_guard_ids", "required_quality_gate_ids"],
|
||||
"properties": {
|
||||
"required_guard_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^GR-[A-Z]+-[0-9]{3}$"
|
||||
}
|
||||
},
|
||||
"required_quality_gate_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^QG-[0-9]{3}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"success_criteria": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "criterion"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^SC-[0-9]{3}$"
|
||||
},
|
||||
"criterion": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"implementation_steps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "title", "description", "guard_refs"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"guard_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^GR-[A-Z]+-[0-9]{3}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tests": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"acceptance_checks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"ux_notes": {
|
||||
"type": "object",
|
||||
"description": "Required for tasks touching UI (templates, phtml pages, web/js, web/css). Leave fields as empty arrays if not applicable.",
|
||||
"properties": {
|
||||
"affected_patterns": {
|
||||
"type": "array",
|
||||
"description": "Existing UI patterns or partials touched by this task.",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"ui_states_required": {
|
||||
"type": "array",
|
||||
"description": "UI states the new view/component must handle: loading, empty, error, success.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["loading", "empty", "error", "success"]
|
||||
}
|
||||
},
|
||||
"a11y_touchpoints": {
|
||||
"type": "array",
|
||||
"description": "New interactive elements that must be keyboard-operable and use semantic HTML.",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"risks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["risk", "mitigation"],
|
||||
"properties": {
|
||||
"risk": { "type": "string", "minLength": 1 },
|
||||
"mitigation": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
81
agent-system/contracts/reviewer-acceptance.schema.json
Normal file
81
agent-system/contracts/reviewer-acceptance.schema.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Acceptance Reviewer Output",
|
||||
"type": "object",
|
||||
"required": ["task_id", "verdict", "checked_criterion_ids", "checks"],
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "minLength": 1 },
|
||||
"verdict": { "type": "string", "enum": ["pass", "fail"] },
|
||||
"checked_criterion_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^SC-[0-9]{3}$"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["criterion_id", "criterion", "result", "evidence"],
|
||||
"properties": {
|
||||
"criterion_id": {
|
||||
"type": "string",
|
||||
"pattern": "^SC-[0-9]{3}$"
|
||||
},
|
||||
"criterion": { "type": "string", "minLength": 1 },
|
||||
"result": { "type": "string", "enum": ["pass", "fail"] },
|
||||
"evidence": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"missing_or_wrong": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "verdict": { "const": "pass" } },
|
||||
"required": ["verdict"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"checks": {
|
||||
"not": {
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"properties": { "result": { "const": "fail" } },
|
||||
"required": ["result"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "verdict": { "const": "fail" } },
|
||||
"required": ["verdict"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["missing_or_wrong"],
|
||||
"properties": {
|
||||
"missing_or_wrong": { "type": "array", "minItems": 1 },
|
||||
"checks": {
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"properties": { "result": { "const": "fail" } },
|
||||
"required": ["result"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
46
agent-system/contracts/reviewer-guards.schema.json
Normal file
46
agent-system/contracts/reviewer-guards.schema.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Guard Reviewer Output",
|
||||
"type": "object",
|
||||
"required": ["task_id", "verdict", "checked_guard_ids", "checked_quality_gate_ids", "findings"],
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "minLength": 1 },
|
||||
"verdict": { "type": "string", "enum": ["pass", "fail"] },
|
||||
"checked_guard_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^GR-[A-Z]+-[0-9]{3}$"
|
||||
}
|
||||
},
|
||||
"checked_quality_gate_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^QG-[0-9]{3}$"
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "severity", "rule_ref", "summary", "file"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"severity": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
|
||||
"rule_ref": {
|
||||
"type": "string",
|
||||
"pattern": "^(GR-[A-Z]+-[0-9]{3}|QG-[0-9]{3})$"
|
||||
},
|
||||
"summary": { "type": "string", "minLength": 1 },
|
||||
"file": { "type": "string", "minLength": 1 },
|
||||
"fix_required": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
25
agent-system/prompts/executor.md
Normal file
25
agent-system/prompts/executor.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Executor Prompt Baseline
|
||||
|
||||
Goal:
|
||||
- implement the approved plan with minimal scope drift
|
||||
|
||||
Required references:
|
||||
- `tools/codex-skills/core-guardrails/SKILL.md`
|
||||
- `tools/codex-skills/starterkit-grid-standards/SKILL.md` (for UI/list tasks)
|
||||
- `tools/codex-skills/starterkit-php-style-ci/SKILL.md` (for PHP style checks)
|
||||
- `agent-system/checks/guard-catalog.json`
|
||||
- `agent-system/checks/quality-gates.json`
|
||||
|
||||
Input:
|
||||
- `plan.json`
|
||||
|
||||
Output:
|
||||
- valid JSON by `agent-system/contracts/executor.schema.json`
|
||||
|
||||
Rules:
|
||||
- only implement items in plan scope
|
||||
- run relevant checks and include command results
|
||||
- provide guard evidence for each required guard ID
|
||||
- report quality gate results by gate ID
|
||||
- list each changed file with a short reason
|
||||
- if blocked, set status to `blocked` and list exact blockers
|
||||
18
agent-system/prompts/finalizer.md
Normal file
18
agent-system/prompts/finalizer.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Finalizer Prompt Baseline
|
||||
|
||||
Goal:
|
||||
- finalize only when both reviews are pass and checks are green
|
||||
|
||||
Input:
|
||||
- `execution-report.json`
|
||||
- `review-guards.json`
|
||||
- `review-acceptance.json`
|
||||
|
||||
Output:
|
||||
- valid JSON by `agent-system/contracts/finalizer.schema.json`
|
||||
|
||||
Rules:
|
||||
- do not finalize if any reviewer verdict is `fail`
|
||||
- do not finalize unless `guard_review=pass`, `acceptance_review=pass`, and `ci_status=pass`
|
||||
- set `final_action` to `hold` and populate `hold_reason` (non-empty string) whenever action is `hold`
|
||||
- `hold_reason` is required by schema when `final_action` is `hold`
|
||||
28
agent-system/prompts/planner.md
Normal file
28
agent-system/prompts/planner.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Planner Prompt Baseline
|
||||
|
||||
Goal:
|
||||
- transform issue input into a decision-complete plan
|
||||
|
||||
Required references:
|
||||
- `tools/codex-skills/starterkit-planner/SKILL.md`
|
||||
- `tools/codex-skills/core-guardrails/SKILL.md`
|
||||
- `tools/codex-skills/starterkit-grid-standards/SKILL.md` (for UI/list tasks)
|
||||
- `agent-system/checks/guard-catalog.json`
|
||||
- `agent-system/checks/quality-gates.json`
|
||||
|
||||
Output:
|
||||
- valid JSON by `agent-system/contracts/planner.schema.json`
|
||||
|
||||
Rules:
|
||||
- define explicit in-scope and out-of-scope
|
||||
- define measurable success criteria with stable IDs (`SC-001`, `SC-002`, ...)
|
||||
- select required guard IDs from guard catalog
|
||||
- select required quality gate IDs from quality gates list
|
||||
- include at least one risk with mitigation
|
||||
- keep implementation steps actionable and ordered
|
||||
|
||||
UI task rules (apply when the task touches templates, pages/*.phtml, or web/js|css):
|
||||
- populate `ux_notes.affected_patterns`: list every existing UI pattern or partial being touched (e.g. "app-details-titlebar", "initStandardListPage")
|
||||
- populate `ux_notes.ui_states_required`: declare all four states that the new view/component must handle — loading, empty, error, success (GR-UI-014); omit only with explicit justification
|
||||
- populate `ux_notes.a11y_touchpoints`: list every new interactive element and confirm it will be keyboard-operable and use semantic HTML (GR-UI-013)
|
||||
- if any item in `ux_notes` is not applicable, set it to an empty array `[]` — do not omit the field
|
||||
17
agent-system/prompts/reviewer-acceptance.md
Normal file
17
agent-system/prompts/reviewer-acceptance.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Reviewer Acceptance Prompt Baseline
|
||||
|
||||
Goal:
|
||||
- verify the feature or issue is implemented exactly as requested
|
||||
|
||||
Required references:
|
||||
- `plan.json`
|
||||
- `agent-system/checks/acceptance-checklist.md`
|
||||
|
||||
Output:
|
||||
- valid JSON by `agent-system/contracts/reviewer-acceptance.schema.json`
|
||||
|
||||
Rules:
|
||||
- evaluate each `plan.json` success criterion by its `SC-*` ID
|
||||
- include evidence for pass/fail checks
|
||||
- return `checked_criterion_ids` and per-check `criterion_id` values that match the plan
|
||||
- list missing behavior explicitly when verdict is `fail`
|
||||
20
agent-system/prompts/reviewer-guards.md
Normal file
20
agent-system/prompts/reviewer-guards.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Reviewer Guards Prompt Baseline
|
||||
|
||||
Goal:
|
||||
- verify compliance with guardrails, architecture, and security expectations
|
||||
|
||||
Required references:
|
||||
- `tools/codex-skills/core-guardrails/SKILL.md`
|
||||
- `tools/codex-skills/starterkit-grid-standards/SKILL.md` (for UI/list tasks)
|
||||
- `tools/codex-skills/starterkit-php-style-ci/SKILL.md` (for PHP style checks)
|
||||
- `agent-system/checks/guard-checklist.md`
|
||||
- `agent-system/checks/guard-catalog.json`
|
||||
- `agent-system/checks/quality-gates.json`
|
||||
|
||||
Output:
|
||||
- valid JSON by `agent-system/contracts/reviewer-guards.schema.json`
|
||||
|
||||
Rules:
|
||||
- findings must reference a concrete guard or gate ID
|
||||
- findings must include file path
|
||||
- use `fail` only when changes are required before finalize
|
||||
1
agent-system/runs/.gitkeep
Normal file
1
agent-system/runs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
39
agent-system/templates/execution-report.template.json
Normal file
39
agent-system/templates/execution-report.template.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"task_id": "TASK-0001",
|
||||
"plan_ref": "workflows/TASK-0001/plan.json",
|
||||
"status": "done",
|
||||
"changed_files": [
|
||||
{
|
||||
"path": "path/to/file",
|
||||
"summary": "What changed"
|
||||
}
|
||||
],
|
||||
"guard_evidence": [
|
||||
{
|
||||
"guard_id": "GR-CORE-001",
|
||||
"status": "pass",
|
||||
"evidence": "Short evidence with file or test reference."
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"cmd": "example command",
|
||||
"result": "pass"
|
||||
}
|
||||
],
|
||||
"quality_gate_results": [
|
||||
{
|
||||
"gate_id": "QG-001",
|
||||
"result": "pass",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"test_results": [
|
||||
{
|
||||
"name": "test name",
|
||||
"result": "pass",
|
||||
"notes": ""
|
||||
}
|
||||
],
|
||||
"open_items": []
|
||||
}
|
||||
11
agent-system/templates/finalize.template.json
Normal file
11
agent-system/templates/finalize.template.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"task_id": "TASK-0001",
|
||||
"ready_to_finalize": false,
|
||||
"guard_review": "pass",
|
||||
"acceptance_review": "pass",
|
||||
"ci_status": "unknown",
|
||||
"final_action": "hold",
|
||||
"hold_reason": "Waiting for mandatory quality gates and review pass signals.",
|
||||
"commit_message": "",
|
||||
"notes": ""
|
||||
}
|
||||
49
agent-system/templates/plan.template.json
Normal file
49
agent-system/templates/plan.template.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"task_id": "TASK-0001",
|
||||
"summary": "Short summary of issue or feature",
|
||||
"assumptions": [],
|
||||
"scope": {
|
||||
"in": [],
|
||||
"out": []
|
||||
},
|
||||
"guardrails": {
|
||||
"required_guard_ids": [
|
||||
"GR-CORE-001",
|
||||
"GR-CORE-003",
|
||||
"GR-CORE-005"
|
||||
],
|
||||
"required_quality_gate_ids": [
|
||||
"QG-001",
|
||||
"QG-002",
|
||||
"QG-003"
|
||||
]
|
||||
},
|
||||
"success_criteria": [
|
||||
{
|
||||
"id": "SC-001",
|
||||
"criterion": "Measurable acceptance criterion."
|
||||
}
|
||||
],
|
||||
"implementation_steps": [
|
||||
{
|
||||
"id": "S1",
|
||||
"title": "Step title",
|
||||
"description": "Step detail",
|
||||
"guard_refs": [
|
||||
"GR-CORE-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tests": [
|
||||
"Test name or PHPUnit class that proves the feature works"
|
||||
],
|
||||
"acceptance_checks": [
|
||||
"SC-001: Verifiable acceptance step for criterion SC-001"
|
||||
],
|
||||
"risks": [
|
||||
{
|
||||
"risk": "Risk description",
|
||||
"mitigation": "Mitigation description"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
agent-system/templates/review-acceptance.template.json
Normal file
16
agent-system/templates/review-acceptance.template.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"task_id": "TASK-0001",
|
||||
"verdict": "pass",
|
||||
"checked_criterion_ids": [
|
||||
"SC-001"
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"criterion_id": "SC-001",
|
||||
"criterion": "criterion text",
|
||||
"result": "pass",
|
||||
"evidence": "Reference to proof (test, file, manual smoke step)."
|
||||
}
|
||||
],
|
||||
"missing_or_wrong": []
|
||||
}
|
||||
15
agent-system/templates/review-guards.template.json
Normal file
15
agent-system/templates/review-guards.template.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"task_id": "TASK-0001",
|
||||
"verdict": "pass",
|
||||
"checked_guard_ids": [
|
||||
"GR-CORE-001",
|
||||
"GR-CORE-003",
|
||||
"GR-CORE-005"
|
||||
],
|
||||
"checked_quality_gate_ids": [
|
||||
"QG-001",
|
||||
"QG-002",
|
||||
"QG-003"
|
||||
],
|
||||
"findings": []
|
||||
}
|
||||
@@ -35,6 +35,7 @@ return [
|
||||
'css/components/app-list-toolbar.css',
|
||||
'css/components/app-filter-drawer.css',
|
||||
'css/components/app-active-filter-chips.css',
|
||||
'css/components/app-confirm-dialog.css',
|
||||
'css/components/app-breadcrumb.css',
|
||||
'css/components/app-tile.css',
|
||||
'css/components/app-tooltips.css',
|
||||
|
||||
@@ -11,4 +11,7 @@ return [
|
||||
'api_token_max_ttl_days' => '365',
|
||||
'api_cors_allowed_origins' => 'http://localhost:8080
|
||||
http://127.0.0.1:8080',
|
||||
'frontend_telemetry_enabled' => '1',
|
||||
'frontend_telemetry_sample_rate' => '0.2',
|
||||
'frontend_telemetry_allowed_events' => 'warn_once,ajax_error',
|
||||
];
|
||||
|
||||
@@ -892,7 +892,9 @@ ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
INSERT INTO `settings` (`key`, `value`, `description`)
|
||||
VALUES
|
||||
('system_audit_enabled', '1', 'setting.system_audit_enabled'),
|
||||
('system_audit_retention_days', '365', 'setting.system_audit_retention_days')
|
||||
('system_audit_retention_days', '365', 'setting.system_audit_retention_days'),
|
||||
('frontend_telemetry_enabled', '0', 'setting.frontend_telemetry_enabled'),
|
||||
('frontend_telemetry_sample_rate', '0.2', 'setting.frontend_telemetry_sample_rate'),
|
||||
('frontend_telemetry_allowed_events', 'warn_once,ajax_error', 'setting.frontend_telemetry_allowed_events')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`value` = VALUES(`value`),
|
||||
`description` = VALUES(`description`);
|
||||
`key` = `key`;
|
||||
|
||||
9
db/updates/2026-03-05-frontend-telemetry-settings.sql
Normal file
9
db/updates/2026-03-05-frontend-telemetry-settings.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Idempotent update: add frontend telemetry settings defaults.
|
||||
|
||||
INSERT INTO `settings` (`key`, `value`, `description`)
|
||||
VALUES
|
||||
('frontend_telemetry_enabled', '0', 'setting.frontend_telemetry_enabled'),
|
||||
('frontend_telemetry_sample_rate', '0.2', 'setting.frontend_telemetry_sample_rate'),
|
||||
('frontend_telemetry_allowed_events', 'warn_once,ajax_error', 'setting.frontend_telemetry_allowed_events')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`key` = `key`;
|
||||
40
docs/agents/flow.md
Normal file
40
docs/agents/flow.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Agent Workflow
|
||||
|
||||
## State Machine
|
||||
- `planned`
|
||||
- `executing`
|
||||
- `review_guard`
|
||||
- `review_acceptance`
|
||||
- `finalize`
|
||||
- `done`
|
||||
|
||||
If a reviewer returns `FAIL`, state goes back to `executing` with explicit findings.
|
||||
|
||||
## Minimum Handover Artifacts
|
||||
- Planner: `plan.json`
|
||||
- Executor: `execution-report.json`
|
||||
- Reviewer Guards: `review-guards.json`
|
||||
- Reviewer Acceptance: `review-acceptance.json`
|
||||
- Finalizer: `finalize.json`
|
||||
|
||||
## Guard and Gate Binding
|
||||
- Planner selects required guard IDs from `agent-system/checks/guard-catalog.json`.
|
||||
- Planner selects required quality gate IDs from `agent-system/checks/quality-gates.json`.
|
||||
- Planner assigns stable success criteria IDs (`SC-*`) in `plan.json`.
|
||||
- Executor must provide guard evidence and gate results by ID.
|
||||
- Guard reviewer verifies and reports verdict against the same IDs.
|
||||
- Acceptance reviewer verifies and reports verdict against the same `SC-*` IDs.
|
||||
|
||||
## Fail Loop Rule
|
||||
- No free-text "please improve".
|
||||
- Every fail must include:
|
||||
- `id`
|
||||
- `severity`
|
||||
- `file`
|
||||
- expected fix
|
||||
|
||||
## Practical Rollout
|
||||
- Start with one small issue.
|
||||
- Run workflow manually with templates.
|
||||
- Stabilize contracts first.
|
||||
- Add automation only after 3 to 5 successful runs.
|
||||
27
docs/agents/overview.md
Normal file
27
docs/agents/overview.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Agent Workflow Overview
|
||||
|
||||
This project uses a multi-role workflow for issue and feature delivery.
|
||||
|
||||
Roles:
|
||||
- Planner
|
||||
- Executor
|
||||
- Reviewer (guards and best practices)
|
||||
- Reviewer (feature acceptance)
|
||||
- Finalizer
|
||||
|
||||
Primary goals:
|
||||
- predictable handovers
|
||||
- strict quality gates
|
||||
- fast retry loop from reviewers back to executor
|
||||
- explicit guard and gate IDs (`GR-*`, `QG-*`) across all roles
|
||||
|
||||
Source of truth:
|
||||
- guardrails and planning standards in `tools/codex-skills/`
|
||||
- contracts and templates in `agent-system/`
|
||||
- guard catalog: `agent-system/checks/guard-catalog.json`
|
||||
- quality gates: `agent-system/checks/quality-gates.json`
|
||||
|
||||
Entry points:
|
||||
- roles: `docs/agents/roles.md`
|
||||
- workflow: `docs/agents/flow.md`
|
||||
- schemas and prompts: `agent-system/`
|
||||
47
docs/agents/roles.md
Normal file
47
docs/agents/roles.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Agent Roles
|
||||
|
||||
## Planner
|
||||
- Input: issue or feature request
|
||||
- Output: `plan.json` (see `agent-system/contracts/planner.schema.json`)
|
||||
- Must define:
|
||||
- scope in and out
|
||||
- required guard IDs (`GR-*`)
|
||||
- required quality gate IDs (`QG-*`)
|
||||
- success criteria with stable IDs (`SC-*`)
|
||||
- implementation steps
|
||||
- test and acceptance checks
|
||||
- risks and mitigations
|
||||
|
||||
## Executor
|
||||
- Input: approved `plan.json`
|
||||
- Output: `execution-report.json`
|
||||
- Must:
|
||||
- implement the plan
|
||||
- run relevant checks
|
||||
- document changed files and commands
|
||||
|
||||
## Reviewer Guards
|
||||
- Input: code diff and `execution-report.json`
|
||||
- Output: `review-guards.json`
|
||||
- Focus:
|
||||
- architecture boundaries
|
||||
- security
|
||||
- coding standards
|
||||
- guardrail compliance
|
||||
- explicit validation against required `GR-*` and `QG-*` IDs
|
||||
|
||||
## Reviewer Acceptance
|
||||
- Input: code diff and plan success criteria
|
||||
- Output: `review-acceptance.json`
|
||||
- Focus:
|
||||
- feature correctness
|
||||
- edge cases
|
||||
- acceptance criteria coverage
|
||||
- explicit check mapping by `SC-*` criterion IDs from `plan.json`
|
||||
|
||||
## Finalizer
|
||||
- Input: both review files with PASS verdict
|
||||
- Output: `finalize.json`
|
||||
- Must:
|
||||
- verify gates are green
|
||||
- prepare merge and commit metadata
|
||||
@@ -1,6 +1,6 @@
|
||||
# Frontend JavaScript
|
||||
|
||||
Letzte Aktualisierung: 2026-03-04
|
||||
Letzte Aktualisierung: 2026-03-05
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
@@ -283,9 +283,24 @@ Für standardisierte Detailseiten gilt zusätzlich:
|
||||
Für allgemeine Form-/Button-Confirms außerhalb der Detail-Policy gilt:
|
||||
|
||||
- zentral über `web/js/components/app-confirm-actions.js`
|
||||
- Confirm-Text über `data-confirm-message`
|
||||
- Dialog-Engine zentral über `web/js/core/app-confirm-dialog.js` (`confirmDialog.confirm(...)`)
|
||||
- Confirm-Text über `data-confirm-message` bzw. auf Detail-Aktionen `data-detail-confirm-message`
|
||||
- optionale Dialog-Meta über
|
||||
- `data-confirm-title`, `data-confirm-confirm-label`, `data-confirm-cancel-label`, `data-confirm-variant`, `data-confirm-focus`
|
||||
- analog für Detail-Aktionen: `data-detail-confirm-*`
|
||||
- kein direktes `window.confirm(...)` in App-JS
|
||||
- keine inline `onclick="return confirm(...)"` / `onsubmit="return confirm(...)"` verwenden
|
||||
|
||||
### Frontend Telemetry Standard
|
||||
|
||||
Für UX-/Fehler-Telemetrie im Frontend gilt:
|
||||
|
||||
- zentral über `web/js/core/app-telemetry.js` (`telemetry.capture(...)`)
|
||||
- `warnOnce(...)` sendet standardisiert `frontend.warn_once`
|
||||
- AJAX-/Fetch-Fehler laufen zentral über den globalen Fetch-Hook als `frontend.ajax_error`
|
||||
- keine neuen Console-only-Warnpfade für relevante UX-Probleme ohne Telemetrie
|
||||
- Payloads nur mit Whitelist-Feldern und ohne sensitive Rohdaten (keine Tokens/Query-Parameter/Stacktraces)
|
||||
|
||||
### Detail Validation Summary Standard
|
||||
|
||||
Für serverseitige Formularfehler in Create/Edit-Templates gilt:
|
||||
|
||||
@@ -91,3 +91,14 @@ Diese Dokumentation folgt einem klaren Lernpfad (einfach -> fortgeschritten) und
|
||||
- Standard-Prompts für Skill-basierte Planung und Implementierung.
|
||||
- `/docs/dokumentation-erweitern.md`
|
||||
- Regeln für konsistente, wachsende Doku.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
- `/docs/agents/overview.md`
|
||||
- Einstieg in das Rollenmodell und die Artefakte.
|
||||
- `/docs/agents/roles.md`
|
||||
- Klare Verantwortung je Rolle (Planner, Executor, Reviewer, Finalizer).
|
||||
- `/docs/agents/flow.md`
|
||||
- State-Flow mit Fail-Loop zur Executor-Iteration.
|
||||
- `/agent-system/README.md`
|
||||
- Technische Basis mit Contracts, Templates, Prompts und Checks.
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
"Save": "Speichern",
|
||||
"Save & close": "Speichern & schließen",
|
||||
"Cancel": "Abbrechen",
|
||||
"Close": "Schließen",
|
||||
"Confirm": "Bestätigen",
|
||||
"Please confirm": "Bitte bestätigen",
|
||||
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"Active": "Aktiv",
|
||||
"Actions": "Aktionen",
|
||||
"ID": "ID",
|
||||
@@ -342,6 +346,7 @@
|
||||
"New folder": "Neuer Ordner",
|
||||
"Toggle navigation": "Navigation umschalten",
|
||||
"Primary navigation": "Hauptnavigation",
|
||||
"Footer navigation": "Fußzeilen-Navigation",
|
||||
"Language switcher": "Sprachauswahl",
|
||||
"Sort column ascending": "Spalte aufsteigend sortieren",
|
||||
"Sort column descending": "Spalte absteigend sortieren",
|
||||
@@ -683,9 +688,9 @@
|
||||
"Keyboard shortcuts": "Tastenkürzel",
|
||||
"Open search": "Suche öffnen",
|
||||
"Save in edit pages": "Auf Bearbeitungsseiten speichern",
|
||||
"Unsaved changes": "Ungespeicherte Änderungen",
|
||||
"Unsaved change (%d field)": "Ungespeicherte Änderung (%d geändertes Feld)",
|
||||
"Unsaved changes (%d fields)": "Ungespeicherte Änderungen (%d geänderte Felder)",
|
||||
"Unsaved changes": "Ungespeichert",
|
||||
"Unsaved change (%d field)": "Ungespeichert (%d Feld)",
|
||||
"Unsaved changes (%d fields)": "Ungespeichert (%d Felder)",
|
||||
"Please review the following errors": "Bitte prüfen Sie die folgenden Fehler",
|
||||
"You have unsaved changes. Leave without saving?": "Sie haben ungespeicherte Änderungen. Seite ohne Speichern verlassen?",
|
||||
"Switch sidebar section": "Sidebar-Bereich wechseln",
|
||||
@@ -1155,8 +1160,26 @@
|
||||
"User #%d": "Benutzer #%d",
|
||||
"User #%d (deleted)": "Benutzer #%d (gelöscht)",
|
||||
"Retention (days)": "Aufbewahrung (Tage)",
|
||||
"Frontend telemetry": "Frontend-Telemetrie",
|
||||
"Enable frontend telemetry": "Frontend-Telemetrie aktivieren",
|
||||
"Sampling rate": "Sampling-Rate",
|
||||
"Sampling rate (0-1)": "Sampling-Rate (0-1)",
|
||||
"Allowed telemetry events": "Erlaubte Telemetrie-Ereignisse",
|
||||
"warnOnce warnings": "warnOnce-Warnungen",
|
||||
"AJAX errors": "AJAX-Fehler",
|
||||
"Telemetry": "Telemetrie",
|
||||
"Telemetry helps us find UI problems faster.": "Telemetrie hilft uns, UI-Probleme schneller zu finden.",
|
||||
"No sensitive data is stored.": "Es werden keine sensiblen Daten gespeichert.",
|
||||
"Sampling rate defines how many events are saved.": "Die Sampling-Rate bestimmt, wie viele Ereignisse gespeichert werden.",
|
||||
"The percentage of users who will have telemetry enabled.": "Der Anteil der Nutzer, für die Telemetrie aktiv ist.",
|
||||
"Example: 0.2 means about 2 of 10 events.": "Beispiel: 0,2 bedeutet etwa 2 von 10 Ereignissen.",
|
||||
"Advanced telemetry options": "Erweiterte Telemetrie-Optionen",
|
||||
"Only change these options if needed.": "Diese Optionen nur bei Bedarf anpassen.",
|
||||
"setting.system_audit_enabled": "System-Protokolle aktivieren/deaktivieren",
|
||||
"setting.system_audit_retention_days": "Aufbewahrungsdauer in Tagen für System-Protokoll-Einträge",
|
||||
"setting.frontend_telemetry_enabled": "Sichere Frontend-UX- und Fehler-Telemetrie aktivieren/deaktivieren",
|
||||
"setting.frontend_telemetry_sample_rate": "Sampling-Rate zwischen 0 und 1 für akzeptierte Frontend-Telemetrie-Ereignisse",
|
||||
"setting.frontend_telemetry_allowed_events": "Erlaubte Frontend-Telemetrie-Ereignisgruppen (warn_once, ajax_error)",
|
||||
"System audit events (24h)": "System-Protokoll-Ereignisse (24h)",
|
||||
"System audit risks (24h)": "System-Protokoll-Risiken (24h)",
|
||||
"Failed outcomes (24h)": "Fehlgeschlagene Ergebnisse (24h)",
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
"Save": "Save",
|
||||
"Save & close": "Save & close",
|
||||
"Cancel": "Cancel",
|
||||
"Close": "Close",
|
||||
"Confirm": "Confirm",
|
||||
"Please confirm": "Please confirm",
|
||||
"This action cannot be undone.": "This action cannot be undone.",
|
||||
"Active": "Active",
|
||||
"Actions": "Actions",
|
||||
"ID": "ID",
|
||||
@@ -342,6 +346,7 @@
|
||||
"New folder": "New folder",
|
||||
"Toggle navigation": "Toggle navigation",
|
||||
"Primary navigation": "Primary navigation",
|
||||
"Footer navigation": "Footer navigation",
|
||||
"Language switcher": "Language switcher",
|
||||
"Sort column ascending": "Sort column ascending",
|
||||
"Sort column descending": "Sort column descending",
|
||||
@@ -1155,8 +1160,26 @@
|
||||
"User #%d": "User #%d",
|
||||
"User #%d (deleted)": "User #%d (deleted)",
|
||||
"Retention (days)": "Retention (days)",
|
||||
"Frontend telemetry": "Frontend telemetry",
|
||||
"Enable frontend telemetry": "Enable frontend telemetry",
|
||||
"Sampling rate": "Sampling rate",
|
||||
"Sampling rate (0-1)": "Sampling rate (0-1)",
|
||||
"Allowed telemetry events": "Allowed telemetry events",
|
||||
"warnOnce warnings": "warnOnce warnings",
|
||||
"AJAX errors": "AJAX errors",
|
||||
"Telemetry": "Telemetry",
|
||||
"Telemetry helps us find UI problems faster.": "Telemetry helps us find UI problems faster.",
|
||||
"No sensitive data is stored.": "No sensitive data is stored.",
|
||||
"Sampling rate defines how many events are saved.": "Sampling rate defines how many events are saved.",
|
||||
"The percentage of users who will have telemetry enabled.": "The percentage of users who will have telemetry enabled.",
|
||||
"Example: 0.2 means about 2 of 10 events.": "Example: 0.2 means about 2 of 10 events.",
|
||||
"Advanced telemetry options": "Advanced telemetry options",
|
||||
"Only change these options if needed.": "Only change these options if needed.",
|
||||
"setting.system_audit_enabled": "Enable/disable system audit logging",
|
||||
"setting.system_audit_retention_days": "Retention in days for system audit entries",
|
||||
"setting.frontend_telemetry_enabled": "Enable/disable secure frontend UX and error telemetry",
|
||||
"setting.frontend_telemetry_sample_rate": "Sampling rate between 0 and 1 for accepted frontend telemetry events",
|
||||
"setting.frontend_telemetry_allowed_events": "Allowed frontend telemetry event groups (warn_once, ajax_error)",
|
||||
"System audit events (24h)": "System audit events (24h)",
|
||||
"System audit risks (24h)": "System audit risks (24h)",
|
||||
"Failed outcomes (24h)": "Failed outcomes (24h)",
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace MintyPHP\App;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
// Minimal DI container: factories are registered with set() and resolved lazily on first get().
|
||||
// Each service is instantiated at most once — get() returns the same instance on every call.
|
||||
final class AppContainer
|
||||
{
|
||||
/** @var array<string, callable(self): mixed> */
|
||||
@@ -32,6 +34,7 @@ final class AppContainer
|
||||
throw new RuntimeException('Service not bound: ' . $id);
|
||||
}
|
||||
|
||||
// Resolve, cache, and return — factory receives the container for its own dependencies.
|
||||
$this->instances[$id] = ($this->bindings[$id])($this);
|
||||
return $this->instances[$id];
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ final class EnvValidator
|
||||
);
|
||||
}
|
||||
|
||||
// Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
|
||||
public static function isValidationEnabled(): bool
|
||||
{
|
||||
$raw = getenv('APP_CONFIG_VALIDATE');
|
||||
@@ -188,6 +189,7 @@ final class EnvValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
|
||||
if ($appEnv === 'prod') {
|
||||
if ($appUrl !== '' && $appUrlIsValid) {
|
||||
$appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME));
|
||||
@@ -270,6 +272,7 @@ final class EnvValidator
|
||||
return array_values(array_filter(array_map('trim', explode(',', $value))));
|
||||
}
|
||||
|
||||
// Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
|
||||
private static function isValidCryptoKey(string $value): bool
|
||||
{
|
||||
if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) {
|
||||
|
||||
@@ -25,6 +25,8 @@ final class AccessRegistrar implements ContainerRegistrar
|
||||
$c->get(AuthorizationService::class)
|
||||
));
|
||||
$container->set(PermissionService::class, static fn (AppContainer $c): PermissionService => $c->get(AccessServicesFactory::class)->createPermissionService());
|
||||
// RoleService lives in DirectoryServicesFactory (not AccessServicesFactory) because
|
||||
// role management involves directory-level concerns (departments, tenant structure).
|
||||
$container->set(RoleService::class, static fn (AppContainer $c): RoleService => $c->get(DirectoryServicesFactory::class)->createRoleService());
|
||||
$container->set(PermissionRepository::class, static fn (AppContainer $c): PermissionRepository => $c->get(AccessServicesFactory::class)->createPermissionRepository());
|
||||
$container->set(RolePermissionRepository::class, static fn (AppContainer $c): RolePermissionRepository => $c->get(AccessServicesFactory::class)->createRolePermissionRepository());
|
||||
|
||||
@@ -5,7 +5,13 @@ namespace MintyPHP\App\Container\Registrars;
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Http\ApiSystemAuditReporter;
|
||||
use MintyPHP\Http\CookieStore;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\Input\RequestInputFactory;
|
||||
use MintyPHP\Http\RequestRuntime;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStore;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
@@ -14,6 +20,7 @@ use MintyPHP\Service\AddressBook\AddressBookService;
|
||||
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
|
||||
use MintyPHP\Service\Audit\ApiAuditService;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
use MintyPHP\Service\Audit\FrontendTelemetryIngestService;
|
||||
use MintyPHP\Service\Audit\ImportAuditService;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditService;
|
||||
@@ -31,6 +38,7 @@ use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Search\SearchDataService;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Stats\AdminStatsViewDataService;
|
||||
|
||||
final class AppServicesRegistrar implements ContainerRegistrar
|
||||
@@ -51,6 +59,12 @@ final class AppServicesRegistrar implements ContainerRegistrar
|
||||
$container->set(ApiAuditService::class, static fn (AppContainer $c): ApiAuditService => $c->get(AuditServicesFactory::class)->createApiAuditService());
|
||||
$container->set(UserLifecycleAuditService::class, static fn (AppContainer $c): UserLifecycleAuditService => $c->get(AuditServicesFactory::class)->createUserLifecycleAuditService());
|
||||
$container->set(SystemAuditService::class, static fn (AppContainer $c): SystemAuditService => $c->get(AuditServicesFactory::class)->createSystemAuditService());
|
||||
$container->set(FrontendTelemetryIngestService::class, static fn (AppContainer $c): FrontendTelemetryIngestService => new FrontendTelemetryIngestService(
|
||||
$c->get(SystemAuditService::class),
|
||||
$c->get(SettingsFrontendTelemetryGateway::class),
|
||||
$c->get(RateLimiterService::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(ApiSystemAuditReporter::class, static fn (AppContainer $c): ApiSystemAuditReporter => new ApiSystemAuditReporter(
|
||||
$c->get(SystemAuditService::class)
|
||||
));
|
||||
@@ -62,5 +76,11 @@ final class AppServicesRegistrar implements ContainerRegistrar
|
||||
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
|
||||
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
|
||||
$container->set(RequestInputFactory::class, static fn (): RequestInputFactory => new RequestInputFactory());
|
||||
$container->set(SessionStore::class, static fn (): SessionStore => new SessionStore());
|
||||
$container->set(SessionStoreInterface::class, static fn (AppContainer $c): SessionStoreInterface => $c->get(SessionStore::class));
|
||||
$container->set(CookieStore::class, static fn (): CookieStore => new CookieStore());
|
||||
$container->set(CookieStoreInterface::class, static fn (AppContainer $c): CookieStoreInterface => $c->get(CookieStore::class));
|
||||
$container->set(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime());
|
||||
$container->set(RequestRuntimeInterface::class, static fn (AppContainer $c): RequestRuntimeInterface => $c->get(RequestRuntime::class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ final class RepositoryFactoryRegistrar implements ContainerRegistrar
|
||||
$container->set(AccessRepositoryFactory::class, static fn (): AccessRepositoryFactory => new AccessRepositoryFactory());
|
||||
$container->set(UserRepositoryFactory::class, static fn (): UserRepositoryFactory => new UserRepositoryFactory());
|
||||
$container->set(AuthRepositoryFactory::class, static fn (): AuthRepositoryFactory => new AuthRepositoryFactory());
|
||||
// These repositories have no factory because they're standalone — no shared config or gateway needed.
|
||||
$container->set(AdminStatsRepository::class, static fn (): AdminStatsRepository => new AdminStatsRepository());
|
||||
$container->set(SearchQueryRepository::class, static fn (): SearchQueryRepository => new SearchQueryRepository());
|
||||
$container->set(DatabaseSessionRepository::class, static fn (): DatabaseSessionRepository => new DatabaseSessionRepository());
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Access\AccessGatewayFactory;
|
||||
@@ -31,7 +34,6 @@ use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Security\SecurityRepositoryFactory;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingRepositoryFactory;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
@@ -51,17 +53,19 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
));
|
||||
$container->set(AuditServicesFactory::class, static fn (AppContainer $c): AuditServicesFactory => new AuditServicesFactory(
|
||||
$c->get(AuditRepositoryFactory::class),
|
||||
$c->get(SettingGateway::class)
|
||||
$c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway(),
|
||||
$c->get(RequestRuntimeInterface::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(SecurityServicesFactory::class, static fn (AppContainer $c): SecurityServicesFactory => new SecurityServicesFactory(
|
||||
$c->get(SecurityRepositoryFactory::class)
|
||||
));
|
||||
$container->set(MailServicesFactory::class, static fn (AppContainer $c): MailServicesFactory => new MailServicesFactory(
|
||||
$c->get(MailRepositoryFactory::class),
|
||||
$c->get(SettingGateway::class)
|
||||
$c->get(SettingServicesFactory::class)->createSettingsSmtpGateway()
|
||||
));
|
||||
$container->set(BrandingServicesFactory::class, static fn (AppContainer $c): BrandingServicesFactory => new BrandingServicesFactory(
|
||||
$c->get(SettingGateway::class)
|
||||
$c->get(SettingServicesFactory::class)->createSettingsAppGateway()
|
||||
));
|
||||
$container->set(CustomFieldServicesFactory::class, static fn (AppContainer $c): CustomFieldServicesFactory => new CustomFieldServicesFactory(
|
||||
$c->get(TenantRepository::class),
|
||||
@@ -76,7 +80,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(DirectoryServicesFactory::class),
|
||||
$c->get(ImportRepositoryFactory::class)
|
||||
$c->get(ImportRepositoryFactory::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(SchedulerServicesFactory::class, static fn (AppContainer $c): SchedulerServicesFactory => new SchedulerServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
@@ -102,11 +107,14 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
$container->set(AccessServicesFactory::class, static fn (AppContainer $c): AccessServicesFactory => new AccessServicesFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AccessGatewayFactory::class),
|
||||
// Wrapped in a second closure to break the circular dependency:
|
||||
// AccessServicesFactory <-> AccessPolicyFactory via PermissionGateway.
|
||||
static fn (): AccessPolicyFactory => $c->get(AccessPolicyFactory::class)
|
||||
));
|
||||
$container->set(AccessGatewayFactory::class, static fn (AppContainer $c): AccessGatewayFactory => new AccessGatewayFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AuditServicesFactory::class)
|
||||
$c->get(AuditServicesFactory::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory(
|
||||
$c->get(PermissionGateway::class),
|
||||
@@ -137,7 +145,10 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
$c->get(MailServicesFactory::class),
|
||||
$c->get(AuthRepositoryFactory::class),
|
||||
$c->get(AuthGatewayFactory::class),
|
||||
$c->get(DatabaseSessionRepository::class)
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SessionStoreInterface::class),
|
||||
$c->get(CookieStoreInterface::class),
|
||||
$c->get(RequestRuntimeInterface::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,16 @@ use MintyPHP\Service\Branding\BrandingServicesFactory;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Settings\ThemeConfigService;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
|
||||
@@ -24,11 +31,26 @@ final class SettingsRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingService::class, static fn (AppContainer $c): SettingService => $c->get(SettingServicesFactory::class)->createSettingService());
|
||||
$container->set(SettingGateway::class, static fn (AppContainer $c): SettingGateway => $c->get(SettingServicesFactory::class)->createSettingGateway());
|
||||
$container->set(SettingsMetadataGateway::class, static fn (AppContainer $c): SettingsMetadataGateway => $c->get(SettingServicesFactory::class)->createSettingsMetadataGateway());
|
||||
$container->set(SettingsDefaultsGateway::class, static fn (AppContainer $c): SettingsDefaultsGateway => $c->get(SettingServicesFactory::class)->createSettingsDefaultsGateway());
|
||||
$container->set(SettingsAppGateway::class, static fn (AppContainer $c): SettingsAppGateway => $c->get(SettingServicesFactory::class)->createSettingsAppGateway());
|
||||
$container->set(SettingsApiPolicyGateway::class, static fn (AppContainer $c): SettingsApiPolicyGateway => $c->get(SettingServicesFactory::class)->createSettingsApiPolicyGateway());
|
||||
$container->set(SettingsUserLifecycleGateway::class, static fn (AppContainer $c): SettingsUserLifecycleGateway => $c->get(SettingServicesFactory::class)->createSettingsUserLifecycleGateway());
|
||||
$container->set(SettingsSystemAuditGateway::class, static fn (AppContainer $c): SettingsSystemAuditGateway => $c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway());
|
||||
$container->set(SettingsFrontendTelemetryGateway::class, static fn (AppContainer $c): SettingsFrontendTelemetryGateway => $c->get(SettingServicesFactory::class)->createSettingsFrontendTelemetryGateway());
|
||||
$container->set(SettingsMicrosoftGateway::class, static fn (AppContainer $c): SettingsMicrosoftGateway => $c->get(SettingServicesFactory::class)->createSettingsMicrosoftGateway());
|
||||
$container->set(SettingsSmtpGateway::class, static fn (AppContainer $c): SettingsSmtpGateway => $c->get(SettingServicesFactory::class)->createSettingsSmtpGateway());
|
||||
$container->set(SettingCacheService::class, static fn (AppContainer $c): SettingCacheService => $c->get(SettingServicesFactory::class)->createSettingCacheService());
|
||||
$container->set(AdminSettingsService::class, static fn (AppContainer $c): AdminSettingsService => new AdminSettingsService(
|
||||
$c->get(SettingGateway::class),
|
||||
$c->get(SettingsDefaultsGateway::class),
|
||||
$c->get(SettingsAppGateway::class),
|
||||
$c->get(SettingsApiPolicyGateway::class),
|
||||
$c->get(SettingsUserLifecycleGateway::class),
|
||||
$c->get(SettingsSystemAuditGateway::class),
|
||||
$c->get(SettingsFrontendTelemetryGateway::class),
|
||||
$c->get(SettingsMicrosoftGateway::class),
|
||||
$c->get(SettingsSmtpGateway::class),
|
||||
$c->get(SettingsMetadataGateway::class),
|
||||
$c->get(SettingCacheService::class),
|
||||
$c->get(TenantService::class),
|
||||
$c->get(RoleService::class),
|
||||
@@ -38,6 +60,8 @@ final class SettingsRegistrar implements ContainerRegistrar
|
||||
$c->get(SystemAuditService::class)
|
||||
));
|
||||
$container->set(ThemeConfigService::class, static fn (AppContainer $c): ThemeConfigService => $c->get(SettingServicesFactory::class)->createThemeConfigService());
|
||||
// Branding services are registered here because they depend on settings gateways
|
||||
// and are consumed alongside settings (e.g. in UserAccessPdfService and the admin UI).
|
||||
$container->set(BrandingLogoService::class, static fn (AppContainer $c): BrandingLogoService => $c->get(BrandingServicesFactory::class)->createBrandingLogoService());
|
||||
$container->set(BrandingFaviconService::class, static fn (AppContainer $c): BrandingFaviconService => $c->get(BrandingServicesFactory::class)->createBrandingFaviconService());
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ use MintyPHP\App\Container\Registrars\UserRegistrar;
|
||||
|
||||
$container = new AppContainer();
|
||||
|
||||
// Order matters: RepositoryFactoryRegistrar and ServiceFactoryRegistrar must run first
|
||||
// because later registrars pull concrete services from those factories.
|
||||
$registrars = [
|
||||
new RepositoryFactoryRegistrar(),
|
||||
new ServiceFactoryRegistrar(),
|
||||
new AccessRegistrar(),
|
||||
new AuthRegistrar(),
|
||||
new DirectoryRegistrar(),
|
||||
new UserRegistrar(),
|
||||
new SettingsRegistrar(),
|
||||
new AppServicesRegistrar(),
|
||||
new RepositoryFactoryRegistrar(), // raw repository factories (no deps)
|
||||
new ServiceFactoryRegistrar(), // service factories wired with their repo/gateway deps
|
||||
new AccessRegistrar(), // RBAC: permissions, roles, authorization
|
||||
new AuthRegistrar(), // authentication, SSO, tokens
|
||||
new DirectoryRegistrar(), // tenants, departments, scope
|
||||
new UserRegistrar(), // user services and repositories
|
||||
new SettingsRegistrar(), // settings, branding
|
||||
new AppServicesRegistrar(), // leaf services (stats, search, mail, scheduler, ...)
|
||||
];
|
||||
|
||||
foreach ($registrars as $registrar) {
|
||||
|
||||
@@ -9,6 +9,8 @@ use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Auth\AuthScopeGateway;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
// Static facade for API authentication — state is set once per request during ApiBootstrap::init().
|
||||
// Dependencies are injected as lazy resolvers so services are only instantiated when actually needed.
|
||||
class ApiAuth
|
||||
{
|
||||
/** @var (callable(): AuthScopeGateway)|null */
|
||||
@@ -58,6 +60,7 @@ class ApiAuth
|
||||
{
|
||||
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||||
if ($header === '') {
|
||||
// Nginx with PHP-FPM may expose the header under this key instead
|
||||
$header = trim((string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
|
||||
}
|
||||
if ($header === '' || stripos($header, 'Bearer ') !== 0) {
|
||||
@@ -100,9 +103,11 @@ class ApiAuth
|
||||
$roleIds = $userRoleRepository->listRoleIdsByUserId($userId);
|
||||
$permissions = $rolePermissionRepository->listPermissionKeysByRoleIds($roleIds);
|
||||
|
||||
// Resolve tenant context
|
||||
// Resolve tenant context: a token may be scoped to a specific tenant,
|
||||
// otherwise fall back to the user's active tenant context.
|
||||
$tokenTenantId = $result['tenant_id'];
|
||||
if ($tokenTenantId !== null) {
|
||||
// Verify the token's tenant is still assigned to this user
|
||||
$userTenantIds = $scopeGateway->getUserTenantIds($userId);
|
||||
if (!in_array($tokenTenantId, $userTenantIds, true)) {
|
||||
return false;
|
||||
@@ -153,11 +158,14 @@ class ApiAuth
|
||||
return $decision->isAllowed();
|
||||
}
|
||||
|
||||
// Effective tenant for data filtering — may come from token scope or user session.
|
||||
public static function tenantId(): ?int
|
||||
{
|
||||
return self::$currentTenantId;
|
||||
}
|
||||
|
||||
// Non-null only when the token itself was issued for a specific tenant.
|
||||
// Used to restrict access in token-scoped requests (e.g. tenant integrations).
|
||||
public static function scopedTenantId(): ?int
|
||||
{
|
||||
return self::$tokenTenantId;
|
||||
@@ -204,6 +212,7 @@ class ApiAuth
|
||||
|
||||
$scopeGateway = self::scopeGateway();
|
||||
if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
|
||||
// 404 instead of 403 — don't reveal that the resource exists in another tenant
|
||||
ApiResponse::notFound();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Service\Audit\ApiAuditService;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
|
||||
// Entry point bootstrapper called once at the top of every API action.
|
||||
// Handles: request context, CORS, audit start, rate limiting, and optional Bearer auth.
|
||||
class ApiBootstrap
|
||||
{
|
||||
// Two separate rate-limit buckets: one per IP (broad), one per token+IP (fine-grained).
|
||||
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
|
||||
private const API_RATE_SCOPE_IP = 'api.ip';
|
||||
private const API_RATE_LIMIT_TOKEN = 300;
|
||||
@@ -19,8 +22,8 @@ class ApiBootstrap
|
||||
private static bool $shutdownRegistered = false;
|
||||
/** @var (callable(): ApiAuditService)|null */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): SettingGateway)|null */
|
||||
private static $settingGatewayResolver = null;
|
||||
/** @var (callable(): SettingsApiPolicyGateway)|null */
|
||||
private static $settingsApiPolicyGatewayResolver = null;
|
||||
/** @var (callable(): RateLimiterService)|null */
|
||||
private static $rateLimiterServiceResolver = null;
|
||||
/** @var (callable(): ApiSystemAuditReporter)|null */
|
||||
@@ -28,12 +31,12 @@ class ApiBootstrap
|
||||
|
||||
public static function configure(
|
||||
callable $apiAuditServiceResolver,
|
||||
callable $settingGatewayResolver,
|
||||
callable $settingsApiPolicyGatewayResolver,
|
||||
callable $rateLimiterServiceResolver,
|
||||
callable $apiSystemAuditReporterResolver
|
||||
): void {
|
||||
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
|
||||
self::$settingGatewayResolver = $settingGatewayResolver;
|
||||
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
|
||||
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
|
||||
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
|
||||
}
|
||||
@@ -93,7 +96,7 @@ class ApiBootstrap
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedOrigins = array_fill_keys(self::settings()->getApiCorsAllowedOrigins(), true);
|
||||
$allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true);
|
||||
if (!isset($allowedOrigins[$origin])) {
|
||||
return;
|
||||
}
|
||||
@@ -105,6 +108,7 @@ class ApiBootstrap
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
}
|
||||
|
||||
// Guarantees audit records are written even on uncaught fatal errors.
|
||||
private static function registerShutdownHandler(): void
|
||||
{
|
||||
if (self::$shutdownRegistered) {
|
||||
@@ -216,6 +220,8 @@ class ApiBootstrap
|
||||
}
|
||||
}
|
||||
|
||||
// API tokens are formatted as "selector:verifier". The selector (24-char hex) is used
|
||||
// as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs.
|
||||
private static function extractBearerSelector(): string
|
||||
{
|
||||
$token = ApiAuth::extractBearerToken();
|
||||
@@ -232,9 +238,13 @@ class ApiBootstrap
|
||||
return $selector;
|
||||
}
|
||||
|
||||
private static function settings(): SettingGateway
|
||||
private static function settingsApiPolicy(): SettingsApiPolicyGateway
|
||||
{
|
||||
return self::resolveDependency(self::$settingGatewayResolver, SettingGateway::class, 'ApiBootstrap');
|
||||
return self::resolveDependency(
|
||||
self::$settingsApiPolicyGatewayResolver,
|
||||
SettingsApiPolicyGateway::class,
|
||||
'ApiBootstrap'
|
||||
);
|
||||
}
|
||||
|
||||
private static function rateLimiter(): RateLimiterService
|
||||
|
||||
@@ -225,6 +225,7 @@ class ApiResponse
|
||||
];
|
||||
}
|
||||
|
||||
// Empty details must be stdClass so json_encode produces {} instead of [].
|
||||
private static function normalizeDetails(array $details): array|\stdClass
|
||||
{
|
||||
if ($details === []) {
|
||||
|
||||
@@ -91,6 +91,8 @@ class ApiSystemAuditReporter
|
||||
);
|
||||
}
|
||||
|
||||
// Recording strategy: always log writes and server errors; for reads, only log
|
||||
// failures on security-sensitive endpoints (auth, tokens) to keep volume manageable.
|
||||
private function shouldRecord(string $method, string $path, int $statusCode): bool
|
||||
{
|
||||
if ($method === 'OPTIONS') {
|
||||
@@ -123,6 +125,8 @@ class ApiSystemAuditReporter
|
||||
return str_starts_with($path, '/api/v1/me/tokens');
|
||||
}
|
||||
|
||||
// Normalizes dynamic path segments (IDs, UUIDs, token selectors) so audit queries
|
||||
// can group by endpoint pattern rather than individual resource IDs.
|
||||
private function endpointKey(string $path): string
|
||||
{
|
||||
$path = '/' . ltrim(trim($path), '/');
|
||||
|
||||
34
lib/Http/CookieStore.php
Normal file
34
lib/Http/CookieStore.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class CookieStore implements CookieStoreInterface
|
||||
{
|
||||
public function get(string $name): string
|
||||
{
|
||||
return (string) ($_COOKIE[$name] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function set(string $name, string $value, array $options = []): void
|
||||
{
|
||||
setcookie($name, $value, $options);
|
||||
$_COOKIE[$name] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function remove(string $name, array $options = []): void
|
||||
{
|
||||
$removeOptions = ['expires' => time() - 3600, 'path' => '/'];
|
||||
foreach ($options as $key => $value) {
|
||||
$removeOptions[$key] = $value;
|
||||
}
|
||||
|
||||
setcookie($name, '', $removeOptions);
|
||||
unset($_COOKIE[$name]);
|
||||
}
|
||||
}
|
||||
18
lib/Http/CookieStoreInterface.php
Normal file
18
lib/Http/CookieStoreInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface CookieStoreInterface
|
||||
{
|
||||
public function get(string $name): string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function set(string $name, string $value, array $options = []): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function remove(string $name, array $options = []): void;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ final class RequestInputFactory
|
||||
$query = $_GET;
|
||||
$files = $_FILES;
|
||||
|
||||
// API requests carry a JSON body; web requests use $_POST form data.
|
||||
if (defined('MINTY_API_REQUEST')) {
|
||||
return new RequestInput($method, $query, $this->readApiBody(), $files);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ class Request
|
||||
return self::stripBasePath($path);
|
||||
}
|
||||
|
||||
// Returns a safe redirect target after login — guards against open-redirect attacks
|
||||
// by rejecting any value with a scheme or host (i.e. an absolute external URL).
|
||||
public static function safeReturnTarget(string $returnParam = ''): string
|
||||
{
|
||||
if ($returnParam !== '') {
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
// Holds per-request metadata (id, channel, method, path, ip) as a static singleton.
|
||||
// Must be initialized once at the entry point (web, api, cli) via start().
|
||||
final class RequestContext
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
@@ -27,6 +29,7 @@ final class RequestContext
|
||||
self::$context = self::buildDefaultContext();
|
||||
}
|
||||
|
||||
// Returns the request ID, generating one if missing. Use this when you need a guaranteed value.
|
||||
public static function id(): string
|
||||
{
|
||||
self::ensureStarted();
|
||||
@@ -38,6 +41,7 @@ final class RequestContext
|
||||
return $requestId;
|
||||
}
|
||||
|
||||
// Returns the ID only if already set — safe to call before start() (e.g. in error handlers).
|
||||
public static function currentId(): ?string
|
||||
{
|
||||
if (self::$context === null) {
|
||||
@@ -80,6 +84,7 @@ final class RequestContext
|
||||
return self::id();
|
||||
}
|
||||
|
||||
// HMAC when a key is configured (e.g. hashing IPs for audit logs) — plain SHA256 as fallback.
|
||||
public static function hashValue(string $value): string
|
||||
{
|
||||
$secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : '';
|
||||
@@ -191,6 +196,8 @@ final class RequestContext
|
||||
return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : '';
|
||||
}
|
||||
|
||||
// Accept a caller-supplied request ID for cross-service trace correlation.
|
||||
// Only trusted if it's a valid UUID format — arbitrary strings are ignored.
|
||||
private static function requestIdFromHeader(): ?string
|
||||
{
|
||||
$header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));
|
||||
|
||||
66
lib/Http/RequestRuntime.php
Normal file
66
lib/Http/RequestRuntime.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class RequestRuntime implements RequestRuntimeInterface
|
||||
{
|
||||
public function method(): string
|
||||
{
|
||||
$method = strtoupper(trim((string) ($this->context()['method'] ?? '')));
|
||||
if ($method === '') {
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
return substr($method, 0, 8);
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return trim((string) ($this->context()['path'] ?? ''));
|
||||
}
|
||||
|
||||
public function ip(): string
|
||||
{
|
||||
return trim((string) ($this->context()['ip'] ?? ''));
|
||||
}
|
||||
|
||||
public function userAgent(): string
|
||||
{
|
||||
return trim((string) ($this->context()['user_agent'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryParams(): array
|
||||
{
|
||||
/** @var array<string, mixed> $query */
|
||||
$query = $_GET;
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function isSecure(): bool
|
||||
{
|
||||
$https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
|
||||
if (in_array($https, ['on', '1', 'true'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
|
||||
if ($forwarded === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode(',', $forwarded);
|
||||
return trim((string) ($parts[0] ?? '')) === 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function context(): array
|
||||
{
|
||||
RequestContext::ensureStarted();
|
||||
return RequestContext::context();
|
||||
}
|
||||
}
|
||||
21
lib/Http/RequestRuntimeInterface.php
Normal file
21
lib/Http/RequestRuntimeInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface RequestRuntimeInterface
|
||||
{
|
||||
public function method(): string;
|
||||
|
||||
public function path(): string;
|
||||
|
||||
public function ip(): string;
|
||||
|
||||
public function userAgent(): string;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryParams(): array;
|
||||
|
||||
public function isSecure(): bool;
|
||||
}
|
||||
50
lib/Http/SessionStore.php
Normal file
50
lib/Http/SessionStore.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class SessionStore implements SessionStoreInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$session = $this->all();
|
||||
if (!array_key_exists($key, $session)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $session[$key];
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
if (!is_array($_SESSION ?? null)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$session = $this->all();
|
||||
return array_key_exists($key, $session);
|
||||
}
|
||||
|
||||
public function remove(string $key): void
|
||||
{
|
||||
if (!is_array($_SESSION ?? null)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
/** @var array<string, mixed> $session */
|
||||
$session = $_SESSION;
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
19
lib/Http/SessionStoreInterface.php
Normal file
19
lib/Http/SessionStoreInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface SessionStoreInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed;
|
||||
|
||||
public function set(string $key, mixed $value): void;
|
||||
|
||||
public function has(string $key): bool;
|
||||
|
||||
public function remove(string $key): void;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Access;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
|
||||
class AccessGatewayFactory
|
||||
@@ -11,7 +12,8 @@ class AccessGatewayFactory
|
||||
|
||||
public function __construct(
|
||||
private readonly AccessRepositoryFactory $accessRepositoryFactory,
|
||||
private readonly AuditServicesFactory $auditServicesFactory
|
||||
private readonly AuditServicesFactory $auditServicesFactory,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -21,7 +23,8 @@ class AccessGatewayFactory
|
||||
$this->accessRepositoryFactory->createPermissionRepository(),
|
||||
$this->accessRepositoryFactory->createRolePermissionRepository(),
|
||||
$this->accessRepositoryFactory->createUserRoleRepository(),
|
||||
$this->auditServicesFactory->createSystemAuditService()
|
||||
$this->auditServicesFactory->createSystemAuditService(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ class AccessPolicyFactory
|
||||
);
|
||||
}
|
||||
|
||||
// Assembles all policies into a single AuthorizationService.
|
||||
// Policy order doesn't matter — each ability maps to exactly one policy via supports().
|
||||
public function createAuthorizationService(): AuthorizationService
|
||||
{
|
||||
return $this->authorizationService ??= new AuthorizationService([
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace MintyPHP\Service\Access;
|
||||
|
||||
// Immutable value object returned by every authorization policy.
|
||||
// The $attributes bag lets policies carry extra computed data to the caller
|
||||
// (e.g. the capabilities map built during the user-edit context check).
|
||||
class AuthorizationDecision
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -23,6 +23,7 @@ class AuthorizationService
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
// First matching policy wins. No match → 500 (misconfiguration, not a user error).
|
||||
foreach ($this->policies as $policy) {
|
||||
if (!$policy->supports($ability)) {
|
||||
continue;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Access;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||
use MintyPHP\Repository\Access\RolePermissionRepositoryInterface;
|
||||
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
|
||||
@@ -15,7 +16,8 @@ class PermissionService
|
||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||
private readonly RolePermissionRepositoryInterface $rolePermissionRepository,
|
||||
private readonly UserRoleRepositoryInterface $userRoleRepository,
|
||||
private readonly SystemAuditService $systemAuditService
|
||||
private readonly SystemAuditService $systemAuditService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -86,6 +88,8 @@ class PermissionService
|
||||
return [];
|
||||
}
|
||||
|
||||
// Two-tier cache: API requests (stateless) use an in-memory array per-request;
|
||||
// web requests use the session store so permissions survive across the page lifecycle.
|
||||
if ($this->isApiRequest()) {
|
||||
if (!$refresh && isset($this->apiPermissionCache[$userId])) {
|
||||
return $this->apiPermissionCache[$userId];
|
||||
@@ -97,7 +101,7 @@ class PermissionService
|
||||
return $keys;
|
||||
}
|
||||
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
$cache = $this->sessionStore->get('permissions');
|
||||
if ($refresh) {
|
||||
$cache = null;
|
||||
}
|
||||
@@ -109,10 +113,10 @@ class PermissionService
|
||||
}
|
||||
$roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId);
|
||||
$keys = $this->rolePermissionRepository->listPermissionKeysByRoleIds($roleIds);
|
||||
$_SESSION['permissions'] = [
|
||||
$this->sessionStore->set('permissions', [
|
||||
'user_id' => $userId,
|
||||
'keys' => $keys,
|
||||
];
|
||||
]);
|
||||
return $keys;
|
||||
}
|
||||
|
||||
@@ -126,7 +130,7 @@ class PermissionService
|
||||
return $this->apiPermissionCache[$userId] ?? [];
|
||||
}
|
||||
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
$cache = $this->sessionStore->get('permissions');
|
||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||
$keys = $cache['keys'] ?? [];
|
||||
return is_array($keys) ? $keys : [];
|
||||
@@ -141,12 +145,13 @@ class PermissionService
|
||||
return;
|
||||
}
|
||||
|
||||
$cache = $_SESSION['permissions'] ?? null;
|
||||
$cache = $this->sessionStore->get('permissions');
|
||||
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
|
||||
unset($_SESSION['permissions']);
|
||||
$this->sessionStore->remove('permissions');
|
||||
}
|
||||
}
|
||||
|
||||
// MINTY_API_REQUEST is defined in the API entry point to distinguish context at runtime.
|
||||
private function isApiRequest(): bool
|
||||
{
|
||||
return defined('MINTY_API_REQUEST');
|
||||
|
||||
@@ -19,6 +19,7 @@ final class UiAccessService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache key hashes a sorted context so order-independent inputs produce the same key.
|
||||
$normalizedContext = $this->normalizeContext($context);
|
||||
$cacheKey = $actorUserId . '|' . $ability . '|' . sha1(json_encode($normalizedContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '');
|
||||
if (array_key_exists($cacheKey, $this->decisionCache)) {
|
||||
|
||||
@@ -99,6 +99,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_CREATE, $context, false);
|
||||
}
|
||||
|
||||
// Returns the full capabilities map as attributes — the submit handler re-uses it to avoid recalculating.
|
||||
private function authorizeAdminUsersEditContext(array $context): AuthorizationDecision
|
||||
{
|
||||
return $this->buildEditContextDecision($context);
|
||||
@@ -309,6 +310,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
|
||||
// 404 instead of 403 — don't reveal that the resource exists outside the actor's scope.
|
||||
if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
||||
return AuthorizationDecision::deny(404, 'not_found');
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ class AddressBookService
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
// Users can always view their own profile; others require scope access.
|
||||
if (
|
||||
$currentUserId !== $targetUserId
|
||||
&& !$this->directoryGateway->canAccessUser($currentUserId, $targetUserId)
|
||||
@@ -239,6 +240,7 @@ class AddressBookService
|
||||
}
|
||||
$tenantId = (int) ($department['tenant_id'] ?? 0);
|
||||
$label = trim((string) ($department['description'] ?? ''));
|
||||
// Only show departments in tenants the viewer also belongs to — prevents leaking org structure.
|
||||
if ($tenantId <= 0 || $label === '' || !in_array($tenantId, $viewerTenantIds, true)) {
|
||||
continue;
|
||||
}
|
||||
@@ -327,6 +329,7 @@ class AddressBookService
|
||||
return array_values(array_filter($ids, static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
// Accepts either a '||'-delimited DB aggregate string (from GROUP_CONCAT) or a plain array.
|
||||
private function normalizeLabelList($value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
use MintyPHP\Http\ApiAuth;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
|
||||
|
||||
@@ -15,8 +16,10 @@ class ApiAuditService
|
||||
|
||||
private ?array $context = null;
|
||||
|
||||
public function __construct(private readonly ApiAuditLogRepositoryInterface $apiAuditLogRepository)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiAuditLogRepositoryInterface $apiAuditLogRepository,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
) {
|
||||
}
|
||||
|
||||
public function startRequestContext(): void
|
||||
@@ -29,14 +32,13 @@ class ApiAuditService
|
||||
RequestContext::setChannel('api');
|
||||
$requestContext = RequestContext::context();
|
||||
|
||||
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
|
||||
$method = strtoupper(trim($this->requestRuntime->method()));
|
||||
if ($method === 'OPTIONS') {
|
||||
$this->context = ['active' => false, 'finished' => true];
|
||||
return;
|
||||
}
|
||||
|
||||
$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$path = is_string($path) ? trim($path) : '';
|
||||
$path = trim($this->requestRuntime->path());
|
||||
if ($path === '') {
|
||||
$this->context = ['active' => false, 'finished' => true];
|
||||
return;
|
||||
@@ -49,7 +51,7 @@ class ApiAuditService
|
||||
'request_id' => (string) ($requestContext['request_id'] ?? RequestContext::id()),
|
||||
'method' => (string) ($requestContext['method'] ?? substr($method !== '' ? $method : 'GET', 0, 8)),
|
||||
'path' => (string) ($requestContext['path'] ?? substr($path, 0, 255)),
|
||||
'query_json' => $this->buildRedactedQueryJson($_GET),
|
||||
'query_json' => $this->buildRedactedQueryJson($this->requestRuntime->queryParams()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,14 +97,14 @@ class ApiAuditService
|
||||
|
||||
$requestContext = RequestContext::context();
|
||||
|
||||
$ip = trim((string) ($requestContext['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')));
|
||||
$ip = trim((string) ($requestContext['ip'] ?? $this->requestRuntime->ip()));
|
||||
if ($ip === '') {
|
||||
$ip = null;
|
||||
} elseif (strlen($ip) > 45) {
|
||||
$ip = substr($ip, 0, 45);
|
||||
}
|
||||
|
||||
$userAgent = trim((string) ($requestContext['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')));
|
||||
$userAgent = trim((string) ($requestContext['user_agent'] ?? $this->requestRuntime->userAgent()));
|
||||
if ($userAgent === '') {
|
||||
$userAgent = null;
|
||||
} elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) {
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Audit\ApiAuditLogRepositoryInterface;
|
||||
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
|
||||
use MintyPHP\Repository\Audit\UserLifecycleAuditRepositoryInterface;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
|
||||
class AuditServicesFactory
|
||||
{
|
||||
@@ -16,7 +18,9 @@ class AuditServicesFactory
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditRepositoryFactory $auditRepositoryFactory,
|
||||
private readonly SettingGateway $settingGateway
|
||||
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
|
||||
private readonly RequestRuntimeInterface $requestRuntime,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,7 +41,10 @@ class AuditServicesFactory
|
||||
|
||||
public function createApiAuditService(): ApiAuditService
|
||||
{
|
||||
return $this->apiAuditService ??= new ApiAuditService($this->createApiAuditLogRepository());
|
||||
return $this->apiAuditService ??= new ApiAuditService(
|
||||
$this->createApiAuditLogRepository(),
|
||||
$this->requestRuntime
|
||||
);
|
||||
}
|
||||
|
||||
public function createUserLifecycleAuditService(): UserLifecycleAuditService
|
||||
@@ -57,7 +64,8 @@ class AuditServicesFactory
|
||||
return $this->systemAuditService ??= new SystemAuditService(
|
||||
$this->createSystemAuditLogRepository(),
|
||||
$this->createSystemAuditRedactionService(),
|
||||
$this->settingGateway
|
||||
$this->settingsSystemAuditGateway,
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
515
lib/Service/Audit/FrontendTelemetryIngestService.php
Normal file
515
lib/Service/Audit/FrontendTelemetryIngestService.php
Normal file
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
|
||||
class FrontendTelemetryIngestService
|
||||
{
|
||||
private const INGEST_RATE_SCOPE = 'frontend.telemetry.ingest';
|
||||
private const SESSION_STATE_KEY = 'frontend_telemetry_state';
|
||||
|
||||
private const INGEST_MAX_HITS = 80;
|
||||
private const INGEST_WINDOW_SECONDS = 300;
|
||||
private const INGEST_BLOCK_SECONDS = 120;
|
||||
private const FAST_DROP_MAX_HITS = 30;
|
||||
private const FAST_DROP_WINDOW_SECONDS = 60;
|
||||
|
||||
private const DEDUPE_WINDOW_SECONDS = 300;
|
||||
|
||||
private const MESSAGE_MAX_LENGTH = 280;
|
||||
private const FINGERPRINT_MAX_LENGTH = 96;
|
||||
private const META_MAX_ENTRIES = 12;
|
||||
private const META_VALUE_MAX_LENGTH = 140;
|
||||
|
||||
private const EVENT_WARN_ONCE = 'frontend.warn_once';
|
||||
private const EVENT_AJAX_ERROR = 'frontend.ajax_error';
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const EVENT_KEY_MAP = [
|
||||
self::EVENT_WARN_ONCE => 'warn_once',
|
||||
self::EVENT_AJAX_ERROR => 'ajax_error',
|
||||
];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const ALLOWED_META_KEYS = [
|
||||
'action',
|
||||
'code',
|
||||
'component',
|
||||
'error_code',
|
||||
'feature',
|
||||
'http_method',
|
||||
'http_status',
|
||||
'location',
|
||||
'module',
|
||||
'page_request_id',
|
||||
'request_path',
|
||||
'source',
|
||||
'status_text',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemAuditService $systemAuditService,
|
||||
private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway,
|
||||
private readonly RateLimiterService $rateLimiterService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{accepted:bool, reason:string}
|
||||
*/
|
||||
public function ingest(array $input): array
|
||||
{
|
||||
if (!$this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled()) {
|
||||
return ['accepted' => false, 'reason' => 'disabled'];
|
||||
}
|
||||
|
||||
$payload = $this->normalizePayload($input);
|
||||
if ($payload === null) {
|
||||
return ['accepted' => false, 'reason' => 'invalid'];
|
||||
}
|
||||
|
||||
if (!$this->isAllowedEvent($payload['event_type'])) {
|
||||
return ['accepted' => false, 'reason' => 'event_not_allowed'];
|
||||
}
|
||||
|
||||
if (!$this->shouldSample($this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate())) {
|
||||
return ['accepted' => false, 'reason' => 'sampled_out'];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$sessionState = $this->loadSessionState();
|
||||
if ($this->applyBurstLimit($sessionState, $now)) {
|
||||
$this->saveSessionState($sessionState);
|
||||
return ['accepted' => false, 'reason' => 'burst_limited'];
|
||||
}
|
||||
|
||||
$subject = $this->subjectKey();
|
||||
|
||||
$rateDecision = $this->rateLimiterService->hit(
|
||||
self::INGEST_RATE_SCOPE,
|
||||
$subject,
|
||||
self::INGEST_MAX_HITS,
|
||||
self::INGEST_WINDOW_SECONDS,
|
||||
self::INGEST_BLOCK_SECONDS
|
||||
);
|
||||
if (!(bool) ($rateDecision['allowed'] ?? true)) {
|
||||
$this->saveSessionState($sessionState);
|
||||
return ['accepted' => false, 'reason' => 'rate_limited'];
|
||||
}
|
||||
|
||||
if ($this->isDuplicateAndTouch($sessionState, $payload['fingerprint_hash'], $now)) {
|
||||
$this->saveSessionState($sessionState);
|
||||
return ['accepted' => false, 'reason' => 'duplicate'];
|
||||
}
|
||||
|
||||
$this->saveSessionState($sessionState);
|
||||
|
||||
$outcome = $payload['severity'] === 'error'
|
||||
? SystemAuditOutcome::Failed->value
|
||||
: SystemAuditOutcome::Success->value;
|
||||
|
||||
$recordId = $this->systemAuditService->record($payload['event_type'], $outcome, [
|
||||
'target_type' => 'frontend',
|
||||
'metadata' => [
|
||||
'severity' => $payload['severity'],
|
||||
'message' => $payload['message'],
|
||||
'fingerprint_hash' => $payload['fingerprint_hash'],
|
||||
'occurred_at' => $payload['occurred_at'],
|
||||
'meta' => $payload['meta'],
|
||||
],
|
||||
]);
|
||||
|
||||
if ($recordId === null) {
|
||||
return ['accepted' => false, 'reason' => 'record_failed'];
|
||||
}
|
||||
|
||||
return ['accepted' => true, 'reason' => 'logged'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{
|
||||
* event_type:string,
|
||||
* severity:string,
|
||||
* message:string,
|
||||
* fingerprint_hash:string,
|
||||
* occurred_at:string,
|
||||
* meta: array<string, mixed>
|
||||
* }|null
|
||||
*/
|
||||
private function normalizePayload(array $input): ?array
|
||||
{
|
||||
$eventType = $this->normalizeEventType((string) ($input['event_type'] ?? ''));
|
||||
if ($eventType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = $this->sanitizeText((string) ($input['message'] ?? ''), self::MESSAGE_MAX_LENGTH);
|
||||
if ($message === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = $this->normalizeMeta($input['meta'] ?? []);
|
||||
$route = trim((string) ($meta['location'] ?? ''));
|
||||
|
||||
$fingerprint = $this->sanitizeFingerprint((string) ($input['fingerprint'] ?? ''));
|
||||
if ($fingerprint === '') {
|
||||
$fingerprint = $this->sanitizeFingerprint(
|
||||
'v1_' . substr(hash('sha256', $eventType . '|' . $route . '|' . $message), 0, 24)
|
||||
);
|
||||
}
|
||||
if ($fingerprint === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'event_type' => $eventType,
|
||||
'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? ''), $eventType),
|
||||
'message' => $message,
|
||||
'fingerprint_hash' => RequestContext::hashValue($fingerprint),
|
||||
'occurred_at' => $this->normalizeOccurredAt((string) ($input['occurred_at'] ?? '')),
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeEventType(string $eventType): string
|
||||
{
|
||||
$eventType = strtolower(trim($eventType));
|
||||
if ($eventType === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($eventType, 'frontend.')) {
|
||||
return isset(self::EVENT_KEY_MAP[$eventType]) ? $eventType : '';
|
||||
}
|
||||
|
||||
foreach (self::EVENT_KEY_MAP as $name => $short) {
|
||||
if ($eventType === $short) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeSeverity(string $severity, string $eventType): string
|
||||
{
|
||||
$severity = strtolower(trim($severity));
|
||||
if (in_array($severity, ['info', 'warning', 'error'], true)) {
|
||||
return $severity;
|
||||
}
|
||||
|
||||
return $eventType === self::EVENT_AJAX_ERROR ? 'error' : 'warning';
|
||||
}
|
||||
|
||||
private function normalizeOccurredAt(string $occurredAt): string
|
||||
{
|
||||
$occurredAt = trim($occurredAt);
|
||||
if ($occurredAt !== '') {
|
||||
try {
|
||||
$date = new DateTimeImmutable($occurredAt);
|
||||
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
|
||||
} catch (\Throwable) {
|
||||
// Fall back to now below.
|
||||
}
|
||||
}
|
||||
|
||||
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
||||
}
|
||||
|
||||
private function sanitizeFingerprint(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = preg_replace('/[^a-z0-9:_-]/', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($value, 0, self::FINGERPRINT_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeMeta(mixed $rawMeta): array
|
||||
{
|
||||
$source = [];
|
||||
if (is_array($rawMeta)) {
|
||||
$source = $rawMeta;
|
||||
} elseif (is_string($rawMeta) && trim($rawMeta) !== '') {
|
||||
$decoded = json_decode($rawMeta, true);
|
||||
if (is_array($decoded)) {
|
||||
$source = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($source === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
$count = 0;
|
||||
foreach ($source as $rawKey => $rawValue) {
|
||||
if ($count >= self::META_MAX_ENTRIES) {
|
||||
break;
|
||||
}
|
||||
|
||||
$key = strtolower(trim((string) $rawKey));
|
||||
if ($key === '' || !in_array($key, self::ALLOWED_META_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'location' || $key === 'request_path') {
|
||||
$normalizedPath = $this->sanitizePath((string) $rawValue);
|
||||
if ($normalizedPath === '') {
|
||||
continue;
|
||||
}
|
||||
$meta[$key] = $normalizedPath;
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'http_status') {
|
||||
$status = (int) $rawValue;
|
||||
if ($status < 100 || $status > 599) {
|
||||
continue;
|
||||
}
|
||||
$meta[$key] = $status;
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'page_request_id') {
|
||||
$requestId = strtolower(trim((string) $rawValue));
|
||||
if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $requestId) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$meta[$key] = $requestId;
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->sanitizeText((string) $rawValue, self::META_VALUE_MAX_LENGTH);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta[$key] = $value;
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private function sanitizePath(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (filter_var($value, FILTER_VALIDATE_URL)) {
|
||||
$parsedPath = parse_url($value, PHP_URL_PATH);
|
||||
$value = is_string($parsedPath) ? $parsedPath : '';
|
||||
}
|
||||
|
||||
$value = (string) preg_replace('/[?#].*/', '', $value);
|
||||
$value = preg_replace('/\/[0-9]{3,}(?=\/|$)/', '/:id', $value) ?? $value;
|
||||
$value = preg_replace(
|
||||
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
|
||||
':uuid',
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (!str_starts_with($value, '/')) {
|
||||
$value = '/' . $value;
|
||||
}
|
||||
|
||||
return substr($value, 0, 255);
|
||||
}
|
||||
|
||||
private function sanitizeText(string $value, int $maxLength): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
$value = preg_replace('/([?&][a-z0-9_.-]{1,64}=)[^&#\s]+/i', '$1[REDACTED]', $value) ?? $value;
|
||||
$value = preg_replace('/(^|\s)(token|access_token|refresh_token|id_token|authorization|api_key|secret)=([^\s]+)/i', '$1$2=[REDACTED]', $value) ?? $value;
|
||||
$value = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $value) ?? $value;
|
||||
$value = preg_replace(
|
||||
'/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
|
||||
'[UUID]',
|
||||
$value
|
||||
) ?? $value;
|
||||
$value = preg_replace('/\b[a-f0-9]{24,}\b/i', '[TOKEN]', $value) ?? $value;
|
||||
|
||||
return substr($value, 0, max(1, $maxLength));
|
||||
}
|
||||
|
||||
private function isAllowedEvent(string $eventType): bool
|
||||
{
|
||||
$eventKey = self::EVENT_KEY_MAP[$eventType] ?? null;
|
||||
if ($eventKey === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents();
|
||||
return in_array($eventKey, $allowed, true);
|
||||
}
|
||||
|
||||
private function shouldSample(float $sampleRate): bool
|
||||
{
|
||||
if ($sampleRate <= 0.0) {
|
||||
return false;
|
||||
}
|
||||
if ($sampleRate >= 1.0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$max = 10000;
|
||||
$threshold = (int) floor($sampleRate * $max);
|
||||
return random_int(1, $max) <= max(1, $threshold);
|
||||
} catch (\Throwable) {
|
||||
$threshold = (int) floor($sampleRate * 10000);
|
||||
return mt_rand(1, 10000) <= max(1, $threshold);
|
||||
}
|
||||
}
|
||||
|
||||
private function subjectKey(): string
|
||||
{
|
||||
$session = $this->sessionStore->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
return 'user:' . $userId;
|
||||
}
|
||||
|
||||
$sessionId = trim(session_id());
|
||||
if ($sessionId !== '') {
|
||||
return 'session:' . $sessionId;
|
||||
}
|
||||
|
||||
$requestContext = RequestContext::context();
|
||||
$ip = trim((string) ($requestContext['ip'] ?? ''));
|
||||
if ($ip !== '') {
|
||||
return 'ip:' . RequestContext::hashValue($ip);
|
||||
}
|
||||
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{dedupe: array<string,int>, burst: array{window_start:int,count:int}}
|
||||
*/
|
||||
private function loadSessionState(): array
|
||||
{
|
||||
$rawState = $this->sessionStore->get(self::SESSION_STATE_KEY, []);
|
||||
if (!is_array($rawState)) {
|
||||
$rawState = [];
|
||||
}
|
||||
|
||||
$dedupe = is_array($rawState['dedupe'] ?? null) ? $rawState['dedupe'] : [];
|
||||
$burst = is_array($rawState['burst'] ?? null) ? $rawState['burst'] : [];
|
||||
|
||||
return [
|
||||
'dedupe' => $dedupe,
|
||||
'burst' => [
|
||||
'window_start' => is_int($burst['window_start'] ?? null) ? (int) $burst['window_start'] : 0,
|
||||
'count' => is_int($burst['count'] ?? null) ? max(0, (int) $burst['count']) : 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
||||
*/
|
||||
private function saveSessionState(array $state): void
|
||||
{
|
||||
$this->sessionStore->set(self::SESSION_STATE_KEY, $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
||||
*/
|
||||
private function applyBurstLimit(array &$state, int $now): bool
|
||||
{
|
||||
$windowStart = (int) ($state['burst']['window_start'] ?? 0);
|
||||
$count = max(0, (int) ($state['burst']['count'] ?? 0));
|
||||
|
||||
if ($windowStart <= 0 || ($now - $windowStart) >= self::FAST_DROP_WINDOW_SECONDS) {
|
||||
$windowStart = $now;
|
||||
$count = 0;
|
||||
}
|
||||
|
||||
if ($count >= self::FAST_DROP_MAX_HITS) {
|
||||
$state['burst'] = [
|
||||
'window_start' => $windowStart,
|
||||
'count' => $count,
|
||||
];
|
||||
return true;
|
||||
}
|
||||
|
||||
$state['burst'] = [
|
||||
'window_start' => $windowStart,
|
||||
'count' => $count + 1,
|
||||
];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
|
||||
*/
|
||||
private function isDuplicateAndTouch(array &$state, string $fingerprintHash, int $now): bool
|
||||
{
|
||||
$seen = $state['dedupe'] ?? [];
|
||||
if (!is_array($seen)) {
|
||||
$seen = [];
|
||||
}
|
||||
|
||||
$cutoff = $now - self::DEDUPE_WINDOW_SECONDS;
|
||||
foreach ($seen as $key => $timestamp) {
|
||||
if (!is_string($key)) {
|
||||
unset($seen[$key]);
|
||||
continue;
|
||||
}
|
||||
if (!is_int($timestamp) || $timestamp < $cutoff) {
|
||||
unset($seen[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($seen[$fingerprintHash]) && (int) $seen[$fingerprintHash] >= $cutoff) {
|
||||
$seen[$fingerprintHash] = $now;
|
||||
$isDuplicate = true;
|
||||
} else {
|
||||
$seen[$fingerprintHash] = $now;
|
||||
$isDuplicate = false;
|
||||
}
|
||||
|
||||
if (count($seen) > 120) {
|
||||
arsort($seen, SORT_NUMERIC);
|
||||
$seen = array_slice($seen, 0, 120, true);
|
||||
}
|
||||
$state['dedupe'] = $seen;
|
||||
return $isDuplicate;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class SystemAuditRedactionService
|
||||
private const MAX_STRING_LENGTH = 500;
|
||||
private const MAX_ARRAY_ITEMS = 50;
|
||||
|
||||
// email/to_email are PII, not credentials, but still redacted to avoid leaking addresses in audit logs.
|
||||
/** @var array<int, string> */
|
||||
private const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
@@ -27,6 +28,8 @@ class SystemAuditRedactionService
|
||||
'to_email',
|
||||
];
|
||||
|
||||
// Only fields on this list show actual before/after values in change sets.
|
||||
// Everything else is recorded as '[REDACTED]' — callers must explicitly opt in to expose change values.
|
||||
/** @var array<int, string> */
|
||||
private const CHANGE_VALUE_ALLOWLIST = [
|
||||
'active',
|
||||
@@ -45,6 +48,9 @@ class SystemAuditRedactionService
|
||||
'user_inactivity_delete_days',
|
||||
'system_audit_enabled',
|
||||
'system_audit_retention_days',
|
||||
'frontend_telemetry_enabled',
|
||||
'frontend_telemetry_sample_rate',
|
||||
'frontend_telemetry_allowed_events',
|
||||
];
|
||||
|
||||
public function redactMetadata(array $metadata): array
|
||||
@@ -157,6 +163,7 @@ class SystemAuditRedactionService
|
||||
return $string;
|
||||
}
|
||||
|
||||
// Use json_encode for arrays to get consistent comparison regardless of type coercion.
|
||||
private function valuesEqual(mixed $left, mixed $right): bool
|
||||
{
|
||||
if (is_array($left) || is_array($right)) {
|
||||
@@ -177,6 +184,7 @@ class SystemAuditRedactionService
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic fallback catches new sensitive keys that weren't explicitly listed.
|
||||
return str_contains($key, 'password')
|
||||
|| str_contains($key, 'secret')
|
||||
|| str_contains($key, 'token')
|
||||
|
||||
@@ -6,9 +6,10 @@ use MintyPHP\Domain\Taxonomy\SystemAuditChannel;
|
||||
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
|
||||
use MintyPHP\Http\ApiAuth;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Audit\SystemAuditLogRepositoryInterface;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
|
||||
class SystemAuditService
|
||||
{
|
||||
@@ -19,7 +20,8 @@ class SystemAuditService
|
||||
public function __construct(
|
||||
private readonly SystemAuditLogRepositoryInterface $systemAuditLogRepository,
|
||||
private readonly SystemAuditRedactionService $systemAuditRedactionService,
|
||||
private readonly SettingGateway $settingGateway
|
||||
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,8 +41,9 @@ class SystemAuditService
|
||||
|
||||
$outcome = $this->normalizeOutcome($outcome);
|
||||
$requestContext = RequestContext::context();
|
||||
$session = is_array($_SESSION ?? null) ? $_SESSION : [];
|
||||
$session = $this->sessionStore->all();
|
||||
|
||||
// Callers can override actor_user_id/actor_tenant_id in $context; otherwise auto-detect from session/API auth.
|
||||
$fallbackActorUserId = !empty($session['user']['id'])
|
||||
? (int) $session['user']['id']
|
||||
: (ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0);
|
||||
@@ -89,6 +92,7 @@ class SystemAuditService
|
||||
'target_uuid' => $targetUuid !== '' ? substr($targetUuid, 0, 36) : null,
|
||||
'method' => $this->normalizeMethod((string) ($requestContext['method'] ?? '')),
|
||||
'path' => $this->normalizePath((string) ($requestContext['path'] ?? '')),
|
||||
// IP and user agent are stored as hashes only — privacy by design, allows correlation without plaintext storage.
|
||||
'ip_hash' => $ip !== '' ? $this->systemAuditRedactionService->hashValue($ip) : null,
|
||||
'user_agent_hash' => $userAgent !== '' ? $this->systemAuditRedactionService->hashValue($userAgent) : null,
|
||||
'metadata_json' => $metadataJson,
|
||||
@@ -125,7 +129,7 @@ class SystemAuditService
|
||||
|
||||
public function retentionDays(): int
|
||||
{
|
||||
$days = (int) $this->settingGateway->getSystemAuditRetentionDays();
|
||||
$days = (int) $this->settingsSystemAuditGateway->getSystemAuditRetentionDays();
|
||||
if ($days < self::RETENTION_DAYS_MIN || $days > self::RETENTION_DAYS_MAX) {
|
||||
return self::RETENTION_DAYS_FALLBACK;
|
||||
}
|
||||
@@ -134,7 +138,7 @@ class SystemAuditService
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->settingGateway->isSystemAuditEnabled();
|
||||
return $this->settingsSystemAuditGateway->isSystemAuditEnabled();
|
||||
}
|
||||
|
||||
private function normalizeEventType(string $eventType): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
@@ -15,7 +16,8 @@ class ApiTokenService
|
||||
private readonly ApiTokenRepositoryInterface $apiTokenRepository,
|
||||
private readonly AuthSettingsGateway $settingsGateway,
|
||||
private readonly AuthScopeGateway $scopeGateway,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -76,6 +78,7 @@ class ApiTokenService
|
||||
return ['ok' => false, 'error' => 'invalid_expires_at'];
|
||||
}
|
||||
|
||||
// Selector is public (used for lookup); verifier is the secret (only its hash is stored).
|
||||
$selector = bin2hex(random_bytes(12));
|
||||
$plainToken = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $plainToken);
|
||||
@@ -140,6 +143,7 @@ class ApiTokenService
|
||||
$tenantId = null;
|
||||
}
|
||||
|
||||
// Wrap revoke+create in a transaction so rotation is atomic — no gap where neither token is valid.
|
||||
$transactionStarted = false;
|
||||
try {
|
||||
$this->databaseSessionRepository->beginTransaction();
|
||||
@@ -260,7 +264,7 @@ class ApiTokenService
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ip = $this->requestRuntime->ip();
|
||||
$this->apiTokenRepository->updateLastUsed((int) $record['id'], $ip);
|
||||
|
||||
return [
|
||||
|
||||
@@ -4,14 +4,20 @@ namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
class AuthGatewayFactory
|
||||
{
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null;
|
||||
private ?SettingsApiPolicyGateway $settingsApiPolicyGateway = null;
|
||||
private ?SettingsDefaultsGateway $settingsDefaultsGateway = null;
|
||||
private ?SettingsAppGateway $settingsAppGateway = null;
|
||||
private ?PermissionGateway $permissionGateway = null;
|
||||
private ?AuthSettingsGateway $authSettingsGateway = null;
|
||||
private ?AuthScopeGateway $authScopeGateway = null;
|
||||
@@ -34,7 +40,12 @@ class AuthGatewayFactory
|
||||
|
||||
public function createAuthSettingsGateway(): AuthSettingsGateway
|
||||
{
|
||||
return $this->authSettingsGateway ??= new AuthSettingsGateway($this->createSettingGateway());
|
||||
return $this->authSettingsGateway ??= new AuthSettingsGateway(
|
||||
$this->createSettingsMicrosoftGateway(),
|
||||
$this->createSettingsApiPolicyGateway(),
|
||||
$this->createSettingsDefaultsGateway(),
|
||||
$this->createSettingsAppGateway()
|
||||
);
|
||||
}
|
||||
|
||||
public function createAuthScopeGateway(): AuthScopeGateway
|
||||
@@ -90,9 +101,24 @@ class AuthGatewayFactory
|
||||
return $this->permissionGateway ??= $this->accessServicesFactory->createPermissionGateway();
|
||||
}
|
||||
|
||||
private function createSettingGateway(): SettingGateway
|
||||
private function createSettingsMicrosoftGateway(): SettingsMicrosoftGateway
|
||||
{
|
||||
return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway();
|
||||
return $this->settingsMicrosoftGateway ??= $this->settingServicesFactory->createSettingsMicrosoftGateway();
|
||||
}
|
||||
|
||||
private function createSettingsApiPolicyGateway(): SettingsApiPolicyGateway
|
||||
{
|
||||
return $this->settingsApiPolicyGateway ??= $this->settingServicesFactory->createSettingsApiPolicyGateway();
|
||||
}
|
||||
|
||||
private function createSettingsDefaultsGateway(): SettingsDefaultsGateway
|
||||
{
|
||||
return $this->settingsDefaultsGateway ??= $this->settingServicesFactory->createSettingsDefaultsGateway();
|
||||
}
|
||||
|
||||
private function createSettingsAppGateway(): SettingsAppGateway
|
||||
{
|
||||
return $this->settingsAppGateway ??= $this->settingServicesFactory->createSettingsAppGateway();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Router;
|
||||
@@ -23,8 +24,9 @@ class AuthService
|
||||
private readonly EmailVerificationService $emailVerificationService,
|
||||
private readonly AuthPermissionGateway $permissionGateway,
|
||||
private readonly AuthTenantSsoGateway $tenantSsoGateway,
|
||||
private readonly AuthSavedFilterGateway $savedFilterGateway,
|
||||
private readonly SystemAuditService $systemAuditService
|
||||
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
||||
private readonly SystemAuditService $systemAuditService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -62,7 +64,8 @@ class AuthService
|
||||
];
|
||||
}
|
||||
|
||||
if (!($_SESSION['user']['active'] ?? 1)) {
|
||||
$sessionUser = $this->sessionUser();
|
||||
if (!($sessionUser['active'] ?? 1)) {
|
||||
Auth::logout();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'account_inactive',
|
||||
@@ -75,7 +78,7 @@ class AuthService
|
||||
];
|
||||
}
|
||||
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$userId = (int) ($sessionUser['id'] ?? 0);
|
||||
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
|
||||
Auth::logout();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
@@ -91,7 +94,7 @@ class AuthService
|
||||
if ($userId > 0) {
|
||||
$this->permissionGateway->warmUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
if ($this->noActiveTenant()) {
|
||||
Auth::logout();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'no_active_tenant',
|
||||
@@ -108,12 +111,14 @@ class AuthService
|
||||
|
||||
$this->recordAuthEvent('auth.login.success', 'success', [
|
||||
'actor_user_id' => $userId > 0 ? $userId : null,
|
||||
'actor_tenant_id' => (int) ($_SESSION['current_tenant']['id'] ?? 0),
|
||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||
]);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Returns true if at least ONE assigned tenant permits local login.
|
||||
// A user is only blocked if ALL tenants enforce Microsoft-only login.
|
||||
private function isLocalPasswordLoginAllowedForUser(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
@@ -168,18 +173,20 @@ class AuthService
|
||||
}
|
||||
|
||||
Session::regenerate();
|
||||
$_SESSION['user'] = $user;
|
||||
$this->sessionStore->set('user', $user);
|
||||
|
||||
if ($preferredTenantId !== null && $preferredTenantId > 0) {
|
||||
$setTenant = $this->userTenantContextService->setCurrentTenant($userId, $preferredTenantId);
|
||||
if ($setTenant['ok'] ?? false) {
|
||||
$_SESSION['user']['current_tenant_id'] = $preferredTenantId;
|
||||
$sessionUser = $this->sessionUser();
|
||||
$sessionUser['current_tenant_id'] = $preferredTenantId;
|
||||
$this->sessionStore->set('user', $sessionUser);
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionGateway->warmUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
if ($this->noActiveTenant()) {
|
||||
Auth::logout();
|
||||
$this->recordAuthEvent('auth.login.failed', 'failed', [
|
||||
'error_code' => 'login_no_active_tenant',
|
||||
@@ -191,7 +198,7 @@ class AuthService
|
||||
$this->userWriteRepository->updateLastLogin($userId, $loginProvider);
|
||||
$this->recordAuthEvent('auth.login.success', 'success', [
|
||||
'actor_user_id' => $userId,
|
||||
'actor_tenant_id' => (int) ($_SESSION['current_tenant']['id'] ?? 0),
|
||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||
'metadata' => ['provider' => $loginProvider],
|
||||
]);
|
||||
return ['ok' => true, 'user' => $user];
|
||||
@@ -209,7 +216,7 @@ class AuthService
|
||||
if (!($result['ok'] ?? false)) {
|
||||
// Check if user needs to verify email
|
||||
if (!empty($result['needs_verification'])) {
|
||||
$_SESSION['email_verification_email'] = $result['email'] ?? $email;
|
||||
$this->sessionStore->set('email_verification_email', $result['email'] ?? $email);
|
||||
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
|
||||
Router::redirect('verify-email');
|
||||
}
|
||||
@@ -221,7 +228,7 @@ class AuthService
|
||||
}
|
||||
|
||||
if ($remember) {
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$userId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$this->rememberMeService->rememberUser($userId);
|
||||
}
|
||||
@@ -250,8 +257,8 @@ class AuthService
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$actorUserId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
$actorTenantId = (int) ($_SESSION['current_tenant']['id'] ?? 0);
|
||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||
$actorTenantId = $this->currentTenantIdFromSession();
|
||||
$this->rememberMeService->forgetCurrentUser();
|
||||
Auth::logout();
|
||||
$this->recordAuthEvent('auth.logout', 'success', [
|
||||
@@ -298,7 +305,9 @@ class AuthService
|
||||
];
|
||||
}
|
||||
|
||||
$sessionVersion = (int) ($_SESSION['user']['authz_version'] ?? 0);
|
||||
// authz_version is bumped in DB whenever permissions/tenants change.
|
||||
// A mismatch means the session is stale and needs to be refreshed.
|
||||
$sessionVersion = (int) ($this->sessionUser()['authz_version'] ?? 0);
|
||||
$dbVersion = max(1, (int) ($snapshot['authz_version'] ?? 1));
|
||||
$refreshed = false;
|
||||
|
||||
@@ -312,13 +321,13 @@ class AuthService
|
||||
];
|
||||
}
|
||||
|
||||
$_SESSION['user'] = $user;
|
||||
$this->sessionStore->set('user', $user);
|
||||
$this->permissionGateway->warmUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
$refreshed = true;
|
||||
}
|
||||
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
if ($this->noActiveTenant()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'logout_required' => true,
|
||||
@@ -336,31 +345,34 @@ class AuthService
|
||||
|
||||
public function loadTenantDataIntoSession(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$this->authSessionTenantContextService->hydrateForUser($userId);
|
||||
}
|
||||
|
||||
// Load available tenants first
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$_SESSION['available_tenants'] = $availableTenants;
|
||||
$_SESSION['available_departments_by_tenant'] = $this->userTenantContextService->getAvailableDepartmentsByTenant($userId);
|
||||
$_SESSION['address_book_saved_filters'] = $this->savedFilterGateway->listAddressBookFilters($userId);
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionUser(): array
|
||||
{
|
||||
$user = $this->sessionStore->get('user', []);
|
||||
return is_array($user) ? $user : [];
|
||||
}
|
||||
|
||||
if (!$availableTenants) {
|
||||
$_SESSION['no_active_tenant'] = true;
|
||||
unset($_SESSION['current_tenant']);
|
||||
return;
|
||||
}
|
||||
$_SESSION['no_active_tenant'] = false;
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sessionCurrentTenant(): array
|
||||
{
|
||||
$tenant = $this->sessionStore->get('current_tenant', []);
|
||||
return is_array($tenant) ? $tenant : [];
|
||||
}
|
||||
|
||||
// Load current tenant (with fallback to first available)
|
||||
$currentTenant = $this->userTenantContextService->getCurrentTenant($userId);
|
||||
if (!$currentTenant) {
|
||||
// Fallback: use first available tenant
|
||||
$currentTenant = $availableTenants[0];
|
||||
}
|
||||
if ($currentTenant) {
|
||||
$_SESSION['current_tenant'] = $currentTenant;
|
||||
}
|
||||
private function currentTenantIdFromSession(): int
|
||||
{
|
||||
return (int) ($this->sessionCurrentTenant()['id'] ?? 0);
|
||||
}
|
||||
|
||||
private function noActiveTenant(): bool
|
||||
{
|
||||
return (bool) $this->sessionStore->get('no_active_tenant', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
@@ -18,6 +21,7 @@ class AuthServicesFactory
|
||||
private ?RememberMeService $rememberMeService = null;
|
||||
private ?AuthService $authService = null;
|
||||
private ?SsoUserLinkService $ssoUserLinkService = null;
|
||||
private ?AuthSessionTenantContextService $authSessionTenantContextService = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserServicesFactory $userServicesFactory,
|
||||
@@ -25,7 +29,10 @@ class AuthServicesFactory
|
||||
private readonly MailServicesFactory $mailServicesFactory,
|
||||
private readonly AuthRepositoryFactory $authRepositoryFactory,
|
||||
private readonly AuthGatewayFactory $authGatewayFactory,
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,7 +43,8 @@ class AuthServicesFactory
|
||||
$this->authRepositoryFactory->createApiTokenRepository(),
|
||||
$this->authGatewayFactory->createAuthSettingsGateway(),
|
||||
$this->createAuthScopeGateway(),
|
||||
$this->databaseSessionRepository
|
||||
$this->databaseSessionRepository,
|
||||
$this->requestRuntime
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,9 +75,11 @@ class AuthServicesFactory
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->authRepositoryFactory->createRememberTokenRepository(),
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->authGatewayFactory->createAuthPermissionGateway(),
|
||||
$this->authGatewayFactory->createAuthSavedFilterGateway()
|
||||
$this->createAuthSessionTenantContextService(),
|
||||
$this->sessionStore,
|
||||
$this->cookieStore,
|
||||
$this->requestRuntime
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,8 +94,9 @@ class AuthServicesFactory
|
||||
$this->createEmailVerificationService(),
|
||||
$this->authGatewayFactory->createAuthPermissionGateway(),
|
||||
$this->createAuthTenantSsoGateway(),
|
||||
$this->authGatewayFactory->createAuthSavedFilterGateway(),
|
||||
$this->auditServicesFactory->createSystemAuditService()
|
||||
$this->createAuthSessionTenantContextService(),
|
||||
$this->auditServicesFactory->createSystemAuditService(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,7 +126,7 @@ class AuthServicesFactory
|
||||
|
||||
public function createMicrosoftOidcStateStore(): MicrosoftOidcStateStoreService
|
||||
{
|
||||
return $this->microsoftOidcStateStoreService ??= new MicrosoftOidcStateStoreService();
|
||||
return $this->microsoftOidcStateStoreService ??= new MicrosoftOidcStateStoreService($this->sessionStore);
|
||||
}
|
||||
|
||||
public function createMicrosoftOidcService(): MicrosoftOidcService
|
||||
@@ -142,4 +153,13 @@ class AuthServicesFactory
|
||||
{
|
||||
return new OidcHttpGateway();
|
||||
}
|
||||
|
||||
private function createAuthSessionTenantContextService(): AuthSessionTenantContextService
|
||||
{
|
||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->authGatewayFactory->createAuthSavedFilterGateway(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
46
lib/Service/Auth/AuthSessionTenantContextService.php
Normal file
46
lib/Service/Auth/AuthSessionTenantContextService.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
class AuthSessionTenantContextService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly AuthSavedFilterGateway $savedFilterGateway,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
public function hydrateForUser(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$this->sessionStore->set('available_tenants', $availableTenants);
|
||||
$this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId));
|
||||
$this->sessionStore->set('address_book_saved_filters', $this->savedFilterGateway->listAddressBookFilters($userId));
|
||||
|
||||
if (!$availableTenants) {
|
||||
$this->sessionStore->set('no_active_tenant', true);
|
||||
$this->sessionStore->remove('current_tenant');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sessionStore->set('no_active_tenant', false);
|
||||
|
||||
// If the DB has no preferred tenant stored, fall back to the first available one.
|
||||
$currentTenant = $this->userTenantContextService->getCurrentTenant($userId);
|
||||
if (!$currentTenant) {
|
||||
$currentTenant = $availableTenants[0];
|
||||
}
|
||||
|
||||
if ($currentTenant) {
|
||||
$this->sessionStore->set('current_tenant', $currentTenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,51 +2,58 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
|
||||
class AuthSettingsGateway
|
||||
{
|
||||
public function __construct(private readonly SettingGateway $settingGateway)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsMicrosoftGateway $settingsMicrosoftGateway,
|
||||
private readonly SettingsApiPolicyGateway $settingsApiPolicyGateway,
|
||||
private readonly SettingsDefaultsGateway $settingsDefaultsGateway,
|
||||
private readonly SettingsAppGateway $settingsAppGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMicrosoftSharedClientId(): string
|
||||
{
|
||||
return (string) $this->settingGateway->getMicrosoftSharedClientId();
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftSharedClientId();
|
||||
}
|
||||
|
||||
public function getMicrosoftSharedClientSecret(): string
|
||||
{
|
||||
return (string) $this->settingGateway->getMicrosoftSharedClientSecret();
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftSharedClientSecret();
|
||||
}
|
||||
|
||||
public function getMicrosoftAuthority(): string
|
||||
{
|
||||
return (string) $this->settingGateway->getMicrosoftAuthority();
|
||||
return (string) $this->settingsMicrosoftGateway->getMicrosoftAuthority();
|
||||
}
|
||||
|
||||
public function getApiTokenMaxTtlDays(): int
|
||||
{
|
||||
return $this->settingGateway->getApiTokenMaxTtlDays();
|
||||
return $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays();
|
||||
}
|
||||
|
||||
public function getApiTokenDefaultTtlDays(): int
|
||||
{
|
||||
return $this->settingGateway->getApiTokenDefaultTtlDays();
|
||||
return $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays();
|
||||
}
|
||||
|
||||
public function getAppTheme(): string
|
||||
{
|
||||
return (string) $this->settingGateway->getAppTheme();
|
||||
return (string) $this->settingsAppGateway->getAppTheme();
|
||||
}
|
||||
|
||||
public function getDefaultRoleId(): int
|
||||
{
|
||||
return (int) $this->settingGateway->getDefaultRoleId();
|
||||
return (int) $this->settingsDefaultsGateway->getDefaultRoleId();
|
||||
}
|
||||
|
||||
public function getDefaultDepartmentId(): int
|
||||
{
|
||||
return (int) $this->settingGateway->getDefaultDepartmentId();
|
||||
return (int) $this->settingsDefaultsGateway->getDefaultDepartmentId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use MintyPHP\I18n;
|
||||
|
||||
class MicrosoftOidcService
|
||||
{
|
||||
// 120s tolerance on exp/nbf checks to handle minor clock drift between systems.
|
||||
private const CLOCK_SKEW = 120;
|
||||
|
||||
public function __construct(
|
||||
@@ -30,6 +31,7 @@ class MicrosoftOidcService
|
||||
}
|
||||
$metadata = $oidc['config'] ?? [];
|
||||
|
||||
// PKCE flow: store code_verifier in session, send only the SHA-256 challenge to the AS.
|
||||
$state = self::randomString(32);
|
||||
$nonce = self::randomString(32);
|
||||
$codeVerifier = self::randomString(64);
|
||||
@@ -461,6 +463,8 @@ class MicrosoftOidcService
|
||||
return is_string($decoded) ? $decoded : '';
|
||||
}
|
||||
|
||||
// Builds a PEM public key from a JWK RSA key (n, e) by constructing the ASN.1 DER structure manually.
|
||||
// PHP's openssl extension doesn't support importing JWK directly, so we encode it ourselves.
|
||||
private static function jwkToPem(array $jwk): ?string
|
||||
{
|
||||
$n = self::base64UrlDecode((string) ($jwk['n'] ?? ''));
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
class MicrosoftOidcStateStoreService
|
||||
{
|
||||
private const SESSION_KEY = 'microsoft_oidc_states';
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly int $ttlSeconds = 600,
|
||||
private readonly int $maxEntries = 50
|
||||
) {
|
||||
@@ -25,6 +28,7 @@ class MicrosoftOidcStateStoreService
|
||||
$payload['created_at'] = (int) ($payload['created_at'] ?? time());
|
||||
$states[$state] = $payload;
|
||||
|
||||
// Cap size to prevent session bloat from many unfinished authorization flows.
|
||||
if (count($states) > $this->maxEntries) {
|
||||
uasort($states, static function (array $a, array $b): int {
|
||||
return ((int) ($a['created_at'] ?? 0)) <=> ((int) ($b['created_at'] ?? 0));
|
||||
@@ -34,7 +38,7 @@ class MicrosoftOidcStateStoreService
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION[self::SESSION_KEY] = $states;
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,8 +57,9 @@ class MicrosoftOidcStateStoreService
|
||||
return ['ok' => false, 'error' => 'state_invalid'];
|
||||
}
|
||||
|
||||
// Remove the state before checking TTL — prevents replay even if the check below rejects it.
|
||||
unset($states[$state]);
|
||||
$_SESSION[self::SESSION_KEY] = $states;
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
|
||||
$createdAt = (int) ($entry['created_at'] ?? 0);
|
||||
if ($createdAt <= 0 || (time() - $createdAt) > $this->ttlSeconds) {
|
||||
@@ -66,12 +71,12 @@ class MicrosoftOidcStateStoreService
|
||||
|
||||
public function prune(): void
|
||||
{
|
||||
$_SESSION[self::SESSION_KEY] = $this->pruneExpiredStates($this->states());
|
||||
$this->sessionStore->set(self::SESSION_KEY, $this->pruneExpiredStates($this->states()));
|
||||
}
|
||||
|
||||
private function states(): array
|
||||
{
|
||||
$states = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$states = $this->sessionStore->get(self::SESSION_KEY, []);
|
||||
return is_array($states) ? $states : [];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class PasswordResetService
|
||||
return ['ok' => false, 'error' => 'email_required'];
|
||||
}
|
||||
|
||||
// Return ok:true even when the email is unknown — prevents email enumeration.
|
||||
$user = $this->userReadRepository->findByEmail($email);
|
||||
if (!$user || !isset($user['id'])) {
|
||||
return ['ok' => true];
|
||||
@@ -148,6 +149,7 @@ class PasswordResetService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// random_int is cryptographically secure; str_pad preserves leading zeros (e.g. "001234").
|
||||
private function generateCode(): string
|
||||
{
|
||||
$max = (10 ** self::CODE_LENGTH) - 1;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Auth;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
use MintyPHP\Session;
|
||||
|
||||
class RememberMeService
|
||||
@@ -19,9 +21,11 @@ class RememberMeService
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly RememberTokenRepositoryInterface $rememberTokenRepository,
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly AuthPermissionGateway $permissionGateway,
|
||||
private readonly AuthSavedFilterGateway $savedFilterGateway
|
||||
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,10 +41,11 @@ class RememberMeService
|
||||
|
||||
public function autoLoginFromCookie(): bool
|
||||
{
|
||||
if (!empty($_SESSION['user']['id'])) {
|
||||
$sessionUser = $this->sessionStore->get('user', []);
|
||||
if (is_array($sessionUser) && !empty($sessionUser['id'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $_COOKIE[$this->cookieName()] ?? '';
|
||||
$value = $this->cookieStore->get($this->cookieName());
|
||||
if ($value === '' || strpos($value, ':') === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -86,15 +91,15 @@ class RememberMeService
|
||||
}
|
||||
|
||||
Session::regenerate();
|
||||
$_SESSION['user'] = $user;
|
||||
$this->sessionStore->set('user', $user);
|
||||
if (!empty($user['locale'])) {
|
||||
I18n::$locale = (string) $user['locale'];
|
||||
}
|
||||
$userId = (int) $user['id'];
|
||||
if ($userId > 0) {
|
||||
$this->permissionGateway->warmUserPermissions($userId, true);
|
||||
$this->loadTenantDataIntoSession($userId);
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
$this->authSessionTenantContextService->hydrateForUser($userId);
|
||||
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
||||
// Enforce the same tenant policy as interactive login.
|
||||
$this->forgetCurrentUser();
|
||||
Auth::logout();
|
||||
@@ -103,6 +108,7 @@ class RememberMeService
|
||||
$this->userWriteRepository->updateLastLogin($userId, 'local');
|
||||
}
|
||||
|
||||
// Rotate token on each auto-login — limits the window if a cookie is stolen.
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$newHash = hash('sha256', $newToken);
|
||||
$newExpires = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds());
|
||||
@@ -114,7 +120,7 @@ class RememberMeService
|
||||
|
||||
public function forgetCurrentUser(): void
|
||||
{
|
||||
$value = $_COOKIE[$this->cookieName()] ?? '';
|
||||
$value = $this->cookieStore->get($this->cookieName());
|
||||
if ($value !== '' && strpos($value, ':') !== false) {
|
||||
[$selector] = explode(':', $value, 2);
|
||||
$selector = trim($selector);
|
||||
@@ -142,10 +148,9 @@ class RememberMeService
|
||||
{
|
||||
$value = $selector . ':' . $token;
|
||||
$expires = time() + $this->lifetimeSeconds();
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$secure = $this->requestRuntime->isSecure();
|
||||
|
||||
setcookie($this->cookieName(), $value, [
|
||||
$this->cookieStore->set($this->cookieName(), $value, [
|
||||
'expires' => $expires,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
@@ -156,10 +161,8 @@ class RememberMeService
|
||||
|
||||
private function clearCookie(): void
|
||||
{
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
setcookie($this->cookieName(), '', [
|
||||
'expires' => time() - 3600,
|
||||
$secure = $this->requestRuntime->isSecure();
|
||||
$this->cookieStore->remove($this->cookieName(), [
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
@@ -178,30 +181,4 @@ class RememberMeService
|
||||
return trim($name) !== '' ? $name : self::COOKIE_NAME;
|
||||
}
|
||||
|
||||
private function loadTenantDataIntoSession(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$_SESSION['available_tenants'] = $availableTenants;
|
||||
$_SESSION['available_departments_by_tenant'] = $this->userTenantContextService->getAvailableDepartmentsByTenant($userId);
|
||||
$_SESSION['address_book_saved_filters'] = $this->savedFilterGateway->listAddressBookFilters($userId);
|
||||
|
||||
if (!$availableTenants) {
|
||||
$_SESSION['no_active_tenant'] = true;
|
||||
unset($_SESSION['current_tenant']);
|
||||
return;
|
||||
}
|
||||
$_SESSION['no_active_tenant'] = false;
|
||||
|
||||
$currentTenant = $this->userTenantContextService->getCurrentTenant($userId);
|
||||
if (!$currentTenant) {
|
||||
$currentTenant = $availableTenants[0];
|
||||
}
|
||||
if ($currentTenant) {
|
||||
$_SESSION['current_tenant'] = $currentTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ class SsoUserLinkService
|
||||
}
|
||||
|
||||
$providerKey = $this->tenantSsoGateway->microsoftProviderKey();
|
||||
// Look up by OID first (stable Microsoft object ID), fall back to issuer+subject
|
||||
// to handle tenants that migrated from an older OIDC setup without OID.
|
||||
$identity = $this->externalIdentityGateway->findByProviderTidOid(
|
||||
$providerKey,
|
||||
$tid,
|
||||
@@ -257,6 +259,8 @@ class SsoUserLinkService
|
||||
return $locale;
|
||||
}
|
||||
|
||||
// SSO users never log in with a password, but the DB column is NOT NULL.
|
||||
// The suffix ensures the hash satisfies any strength validators.
|
||||
private function randomPassword(): string
|
||||
{
|
||||
return 'sso-' . bin2hex(random_bytes(24)) . '-A1!';
|
||||
|
||||
@@ -55,6 +55,7 @@ class TenantSsoService
|
||||
}
|
||||
}
|
||||
|
||||
// Reject ambiguous slugs — if two tenants have the same name-slug, neither matches.
|
||||
if (count($matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -300,6 +301,7 @@ class TenantSsoService
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
// Empty allowedDomains list means all email domains are permitted.
|
||||
public function isEmailDomainAllowed(string $email, array $allowedDomains): bool
|
||||
{
|
||||
$allowedDomains = array_values(array_filter(array_map(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace MintyPHP\Service\Branding;
|
||||
|
||||
use MintyPHP\Service\Image\ImageUploadTrait;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
|
||||
class BrandingFaviconService
|
||||
{
|
||||
@@ -19,7 +19,7 @@ class BrandingFaviconService
|
||||
512 => 'web-app-manifest-512x512.png',
|
||||
];
|
||||
|
||||
public function __construct(private readonly SettingGateway $settingGateway)
|
||||
public function __construct(private readonly SettingsAppGateway $settingsAppGateway)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class BrandingFaviconService
|
||||
}
|
||||
}
|
||||
|
||||
$title = $this->settingGateway->getAppTitle();
|
||||
$title = $this->settingsAppGateway->getAppTitle();
|
||||
if ($title === null || $title === '') {
|
||||
$title = defined('APP_NAME') ? APP_NAME : 'IMVS';
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ class BrandingLogoService
|
||||
}
|
||||
@chmod($originalPath, 0644);
|
||||
|
||||
// SVGs are served as-is (no rasterization); raster images get WebP variants if GD supports it.
|
||||
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
|
||||
if (!$isSvg && self::imageCanResize()) {
|
||||
foreach (self::SIZES as $size) {
|
||||
@@ -127,6 +128,7 @@ class BrandingLogoService
|
||||
return self::DEFAULT_SIZE;
|
||||
}
|
||||
|
||||
// Multiple extensions may exist (e.g. after format change) — pick the most recently modified one.
|
||||
private function findVariantPath(string $dir, int $size): ?string
|
||||
{
|
||||
$matches = glob($dir . '/logo-' . $size . '.*');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Branding;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
|
||||
class BrandingServicesFactory
|
||||
{
|
||||
@@ -10,7 +10,7 @@ class BrandingServicesFactory
|
||||
private ?BrandingFaviconService $brandingFaviconService = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingGateway $settingGateway
|
||||
private readonly SettingsAppGateway $settingsAppGateway
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ class BrandingServicesFactory
|
||||
|
||||
public function createBrandingFaviconService(): BrandingFaviconService
|
||||
{
|
||||
return $this->brandingFaviconService ??= new BrandingFaviconService($this->createSettingGateway());
|
||||
return $this->brandingFaviconService ??= new BrandingFaviconService($this->createSettingsAppGateway());
|
||||
}
|
||||
|
||||
private function createSettingGateway(): SettingGateway
|
||||
private function createSettingsAppGateway(): SettingsAppGateway
|
||||
{
|
||||
return $this->settingGateway;
|
||||
return $this->settingsAppGateway;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ class TenantCustomFieldService
|
||||
return ['ok' => false, 'errors' => [t('Custom field can not be updated')], 'form' => $form];
|
||||
}
|
||||
|
||||
// When type is changed away from select/multiselect, remove orphaned option rows.
|
||||
if (in_array($form['type'], ['select', 'multiselect'], true)) {
|
||||
TenantCustomFieldOptionRepository::replaceForDefinition((int) $existing['id'], $form['options']);
|
||||
} else {
|
||||
@@ -220,6 +221,7 @@ class TenantCustomFieldService
|
||||
$form['options'] = [];
|
||||
}
|
||||
|
||||
// Text/textarea types can't be used as filters — too many distinct values to enumerate in UI.
|
||||
if (!in_array($type, ['select', 'multiselect', 'boolean', 'date'], true)) {
|
||||
$form['is_filterable'] = 0;
|
||||
}
|
||||
@@ -227,6 +229,8 @@ class TenantCustomFieldService
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// Line format: "key|Label" or just "Label" (key is derived from the label when omitted).
|
||||
// Duplicate keys are silently dropped — first occurrence wins.
|
||||
private static function parseOptionsLines(string $value): array
|
||||
{
|
||||
$lines = preg_split('/\r\n|\r|\n/', $value);
|
||||
@@ -261,6 +265,8 @@ class TenantCustomFieldService
|
||||
return array_values($options);
|
||||
}
|
||||
|
||||
// Transliterates accented characters (é → e, ü → u) before slugifying so non-ASCII labels
|
||||
// produce readable keys. Falls back to direct slugification if iconv is unavailable.
|
||||
private static function normalizeKey(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
@@ -307,6 +313,7 @@ class TenantCustomFieldService
|
||||
return '';
|
||||
}
|
||||
|
||||
// Auto-increment suffix until a unique key is found; 10000 guard prevents infinite loop.
|
||||
$candidate = $base;
|
||||
$counter = 2;
|
||||
while (true) {
|
||||
|
||||
@@ -251,7 +251,8 @@ class UserCustomFieldValueService
|
||||
}
|
||||
|
||||
$tenantIds = self::normalizeTenantIds($tenantIds);
|
||||
// Always clean values that belong to tenants no longer assigned to the user.
|
||||
// Always clean values for tenants no longer assigned — happens even when !$canEdit,
|
||||
// because orphaned custom field data should not persist after tenant reassignment.
|
||||
if (!UserCustomFieldValueRepository::deleteByUserOutsideTenantIds($userId, $tenantIds)) {
|
||||
return ['ok' => false, 'errors' => [t('Custom field values can not be saved')]];
|
||||
}
|
||||
@@ -454,6 +455,7 @@ class UserCustomFieldValueService
|
||||
continue;
|
||||
}
|
||||
|
||||
// URL key prefixes: cf_ = single-value (select/boolean), cfm_ = multiselect, cfd_ = date range.
|
||||
if (preg_match('/^cf_([a-f0-9-]{36})$/', $key, $match)) {
|
||||
$uuid = $match[1];
|
||||
$definition = $definitionMapByUuid[$uuid] ?? null;
|
||||
|
||||
@@ -19,7 +19,7 @@ class DirectoryGatewayFactory
|
||||
public function createDirectorySettingsGateway(): DirectorySettingsGateway
|
||||
{
|
||||
return $this->directorySettingsGateway ??= new DirectorySettingsGateway(
|
||||
$this->settingServicesFactory->createSettingGateway()
|
||||
$this->settingServicesFactory->createSettingsDefaultsGateway()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class DirectoryScopeGateway
|
||||
return $this->tenantScopeService->filterTenantIdsForUser($tenantIds, $userId);
|
||||
}
|
||||
|
||||
// When a scoped admin updates tenant assignments, they can't see tenants outside their scope.
|
||||
// This merges the requested IDs with any out-of-scope IDs from existing assignments so those
|
||||
// aren't silently dropped when the admin saves.
|
||||
public function mergeTenantIdsPreservingOutOfScope(
|
||||
array $requestedTenantIds,
|
||||
array $existingTenantIds,
|
||||
|
||||
@@ -55,6 +55,7 @@ class DirectoryServicesFactory
|
||||
);
|
||||
}
|
||||
|
||||
// Passthrough accessors so callers only need a reference to this factory, not the sub-factories.
|
||||
public function createTenantRepository(): TenantRepositoryInterface
|
||||
{
|
||||
return $this->directoryRepositoryFactory->createTenantRepository();
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
namespace MintyPHP\Service\Directory;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
|
||||
class DirectorySettingsGateway
|
||||
{
|
||||
public function __construct(private readonly SettingGateway $settingGateway)
|
||||
public function __construct(private readonly SettingsDefaultsGateway $settingsDefaultsGateway)
|
||||
{
|
||||
}
|
||||
|
||||
public function setDefaultTenantId(?int $tenantId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultTenantId($tenantId);
|
||||
$this->settingsDefaultsGateway->setDefaultTenantId($tenantId);
|
||||
}
|
||||
|
||||
public function setDefaultDepartmentId(?int $departmentId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultDepartmentId($departmentId);
|
||||
$this->settingsDefaultsGateway->setDefaultDepartmentId($departmentId);
|
||||
}
|
||||
|
||||
public function setDefaultRoleId(?int $roleId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultRoleId($roleId);
|
||||
$this->settingsDefaultsGateway->setDefaultRoleId($roleId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ class CsvReaderService
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
// Sample-based detection — reading 5 lines is enough to identify the delimiter without scanning the full file.
|
||||
private function detectDelimiter(string $path): string
|
||||
{
|
||||
$sample = '';
|
||||
@@ -154,6 +155,7 @@ class CsvReaderService
|
||||
}
|
||||
}
|
||||
|
||||
// Case-insensitive duplicate check — "Email" and "email" in the same header row are ambiguous.
|
||||
$normalizedKeys = array_map([$this, 'lower'], $headers);
|
||||
if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) {
|
||||
return ['ok' => false, 'code' => 'invalid_csv_header'];
|
||||
@@ -177,6 +179,7 @@ class CsvReaderService
|
||||
$normalized[] = $this->cleanCell((string) $cell);
|
||||
}
|
||||
|
||||
// Pad short rows and trim over-long rows so every row has exactly as many cells as headers.
|
||||
$current = count($normalized);
|
||||
if ($current < $size) {
|
||||
$normalized = array_pad($normalized, $size, '');
|
||||
@@ -236,6 +239,7 @@ class CsvReaderService
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Remove null bytes (defense against null byte injection) and UTF-8 BOM from each cell.
|
||||
private function cleanCell(string $value): string
|
||||
{
|
||||
$value = str_replace("\0", '', $value);
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace MintyPHP\Service\Import;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\ImportAuditService;
|
||||
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\User\UserScopeGateway;
|
||||
|
||||
class ImportService
|
||||
@@ -28,8 +29,9 @@ class ImportService
|
||||
private readonly ImportStateStoreService $importStateStoreService,
|
||||
private readonly array $profiles,
|
||||
private readonly PermissionGateway $permissionGateway,
|
||||
private readonly SettingGateway $settingGateway,
|
||||
private readonly UserScopeGateway $userScopeGateway
|
||||
private readonly SettingsDefaultsGateway $settingsDefaultsGateway,
|
||||
private readonly UserScopeGateway $userScopeGateway,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -139,6 +141,7 @@ class ImportService
|
||||
if (!$state) {
|
||||
return ['ok' => false, 'code' => 'invalid_file_type'];
|
||||
}
|
||||
// Prevent cross-user token reuse — the state token is session-scoped but user_id is verified explicitly.
|
||||
if ((int) ($state['user_id'] ?? 0) !== $currentUserId) {
|
||||
return ['ok' => false, 'code' => 'invalid_file_type'];
|
||||
}
|
||||
@@ -154,6 +157,8 @@ class ImportService
|
||||
}
|
||||
|
||||
$mappedTargets = $mappingResult['mapped_targets'] ?? [];
|
||||
// Tenant/role/department column mapping requires a separate permission — a user who can import
|
||||
// users generally may not be allowed to also assign them to tenants.
|
||||
if (
|
||||
$profile->requiresAssignmentPermission()
|
||||
&& $this->usesAssignmentTargets($mappedTargets)
|
||||
@@ -203,7 +208,7 @@ class ImportService
|
||||
$mappingResult['mapped_targets'] ?? [],
|
||||
isset($state['source_filename']) ? (string) $state['source_filename'] : null,
|
||||
$currentUserId,
|
||||
isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null
|
||||
$this->currentTenantId()
|
||||
);
|
||||
|
||||
$result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1];
|
||||
@@ -301,6 +306,7 @@ class ImportService
|
||||
|
||||
$normalized = $validation['normalized'];
|
||||
$identifier = $profile->rowIdentifier($normalized);
|
||||
// Detect duplicates within the file itself (e.g. same email appearing twice).
|
||||
$duplicateKey = $profile->inFileDuplicateKey($normalized);
|
||||
if ($duplicateKey !== null && $duplicateKey !== '') {
|
||||
if (isset($seenRowKeys[$duplicateKey])) {
|
||||
@@ -496,14 +502,28 @@ class ImportService
|
||||
return [
|
||||
'current_user_id' => $currentUserId,
|
||||
'is_global_admin' => $isGlobalAdmin,
|
||||
// Empty allowed_tenant_ids signals "no restriction" to profile validators for global admins.
|
||||
'allowed_tenant_ids' => $isGlobalAdmin ? [] : $this->userScopeGateway->getUserTenantIds($currentUserId),
|
||||
'default_tenant_id' => $this->settingGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingGateway->getDefaultDepartmentId(),
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
'can_import_assignments' => $this->canImportAssignments($currentUserId),
|
||||
];
|
||||
}
|
||||
|
||||
private function currentTenantId(): ?int
|
||||
{
|
||||
$tenant = $this->sessionStore->get('current_tenant', []);
|
||||
if (!is_array($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = (int) ($tenant['id'] ?? 0);
|
||||
return $tenantId > 0 ? $tenantId : null;
|
||||
}
|
||||
|
||||
// Fallback to uniqid if random_bytes is unavailable — not cryptographically ideal but only
|
||||
// used for session-scoped state tokens, not for security-critical values.
|
||||
private function newToken(): string
|
||||
{
|
||||
try {
|
||||
@@ -530,6 +550,7 @@ class ImportService
|
||||
/**
|
||||
* @param array<int, array{row_number:int,identifier:string,code:string,message:string}> $errors
|
||||
*/
|
||||
// Cap the error list to avoid oversized responses — error_counts still reflects the full count.
|
||||
private function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void
|
||||
{
|
||||
if (count($errors) >= self::MAX_ERROR_ROWS) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace MintyPHP\Service\Import;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Audit\ImportAuditRunRepositoryInterface;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
@@ -11,7 +12,7 @@ use MintyPHP\Service\Import\Profile\DepartmentImportGateway;
|
||||
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
|
||||
use MintyPHP\Service\Import\Profile\UserImportGateway;
|
||||
use MintyPHP\Service\Import\Profile\UserImportProfile;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
@@ -22,7 +23,7 @@ class ImportServicesFactory
|
||||
private ?ImportStateStoreService $importStateStoreService = null;
|
||||
private ?ImportAuditService $importAuditService = null;
|
||||
private ?PermissionGateway $permissionGateway = null;
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?SettingsDefaultsGateway $settingsDefaultsGateway = null;
|
||||
private ?ImportService $importService = null;
|
||||
|
||||
public function __construct(
|
||||
@@ -30,7 +31,8 @@ class ImportServicesFactory
|
||||
private readonly AccessServicesFactory $accessServicesFactory,
|
||||
private readonly SettingServicesFactory $settingServicesFactory,
|
||||
private readonly DirectoryServicesFactory $directoryServicesFactory,
|
||||
private readonly ImportRepositoryFactory $importRepositoryFactory
|
||||
private readonly ImportRepositoryFactory $importRepositoryFactory,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -62,8 +64,9 @@ class ImportServicesFactory
|
||||
$this->createImportStateStoreService(),
|
||||
$profiles,
|
||||
$this->createPermissionGateway(),
|
||||
$this->createSettingGateway(),
|
||||
$this->userServicesFactory->createUserScopeGateway()
|
||||
$this->createSettingsDefaultsGateway(),
|
||||
$this->userServicesFactory->createUserScopeGateway(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +77,10 @@ class ImportServicesFactory
|
||||
|
||||
public function createImportStateStoreService(): ImportStateStoreService
|
||||
{
|
||||
return $this->importStateStoreService ??= new ImportStateStoreService($this->getImportTempFileService());
|
||||
return $this->importStateStoreService ??= new ImportStateStoreService(
|
||||
$this->getImportTempFileService(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
|
||||
private function getCsvReaderService(): CsvReaderService
|
||||
@@ -97,8 +103,8 @@ class ImportServicesFactory
|
||||
return $this->permissionGateway ??= $this->accessServicesFactory->createPermissionGateway();
|
||||
}
|
||||
|
||||
private function createSettingGateway(): SettingGateway
|
||||
private function createSettingsDefaultsGateway(): SettingsDefaultsGateway
|
||||
{
|
||||
return $this->settingGateway ??= $this->settingServicesFactory->createSettingGateway();
|
||||
return $this->settingsDefaultsGateway ??= $this->settingServicesFactory->createSettingsDefaultsGateway();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
namespace MintyPHP\Service\Import;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
class ImportStateStoreService
|
||||
{
|
||||
private const SESSION_KEY = 'admin_imports_v1';
|
||||
private const STATE_TTL_SECONDS = 7200;
|
||||
|
||||
public function __construct(private readonly ImportTempFileService $importTempFileService)
|
||||
public function __construct(
|
||||
private readonly ImportTempFileService $importTempFileService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -16,10 +21,12 @@ class ImportStateStoreService
|
||||
*/
|
||||
public function setState(string $token, array $state): void
|
||||
{
|
||||
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
|
||||
$_SESSION[self::SESSION_KEY] = [];
|
||||
$states = $this->sessionStore->get(self::SESSION_KEY, []);
|
||||
if (!is_array($states)) {
|
||||
$states = [];
|
||||
}
|
||||
$_SESSION[self::SESSION_KEY][$token] = $state;
|
||||
$states[$token] = $state;
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +39,7 @@ class ImportStateStoreService
|
||||
return null;
|
||||
}
|
||||
|
||||
$all = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$all = $this->sessionStore->get(self::SESSION_KEY, []);
|
||||
if (!is_array($all)) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,6 +49,7 @@ class ImportStateStoreService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lazy expiry: clean up the temp file and session entry when the state is accessed after TTL.
|
||||
$createdAt = (int) ($state['created_at'] ?? 0);
|
||||
if ($createdAt > 0 && (time() - $createdAt) > self::STATE_TTL_SECONDS) {
|
||||
$this->importTempFileService->delete((string) ($state['path'] ?? ''));
|
||||
@@ -54,9 +62,12 @@ class ImportStateStoreService
|
||||
|
||||
public function clearState(string $token): void
|
||||
{
|
||||
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
|
||||
$states = $this->sessionStore->get(self::SESSION_KEY, []);
|
||||
if (!is_array($states)) {
|
||||
return;
|
||||
}
|
||||
unset($_SESSION[self::SESSION_KEY][$token]);
|
||||
|
||||
unset($states[$token]);
|
||||
$this->sessionStore->set(self::SESSION_KEY, $states);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class ImportTempFileService
|
||||
return ['ok' => false, 'code' => 'unexpected_error'];
|
||||
}
|
||||
|
||||
// Lazy cleanup on each upload — removes abandoned files without needing a separate cron job.
|
||||
$this->cleanupExpired();
|
||||
|
||||
try {
|
||||
@@ -47,6 +48,8 @@ class ImportTempFileService
|
||||
} catch (\Throwable $exception) {
|
||||
$target = $dir . '/import_' . str_replace('.', '', uniqid('', true)) . '.csv';
|
||||
}
|
||||
// Real uploads use move_uploaded_file (validates PHP upload context).
|
||||
// Fallback rename/copy path handles test uploads without a real HTTP context.
|
||||
$moved = false;
|
||||
if (is_uploaded_file($tmpName)) {
|
||||
$moved = @move_uploaded_file($tmpName, $target);
|
||||
@@ -122,6 +125,7 @@ class ImportTempFileService
|
||||
return in_array($extension, ['csv', 'txt'], true);
|
||||
}
|
||||
|
||||
// Resolves both paths via realpath to prevent path traversal attacks (e.g. "../../../etc/passwd").
|
||||
private function isInTmpDir(string $path): bool
|
||||
{
|
||||
$tmpDir = $this->tmpDir();
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace MintyPHP\Service\Mail;
|
||||
use MintyPHP\Domain\Taxonomy\MailLogStatus;
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Repository\Mail\MailLogRepositoryInterface;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use PHPMailer\PHPMailer\Exception as MailerException;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
@@ -13,7 +13,7 @@ class MailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailLogRepositoryInterface $mailLogRepository,
|
||||
private readonly SettingGateway $settingGateway
|
||||
private readonly SettingsSmtpGateway $settingsSmtpGateway
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class MailService
|
||||
string $text,
|
||||
array $meta = []
|
||||
): array {
|
||||
// Log before sending so there's a record even if the send throws an exception.
|
||||
$logId = $this->mailLogRepository->create([
|
||||
'to_email' => $to,
|
||||
'subject' => $subject,
|
||||
@@ -90,19 +91,6 @@ class MailService
|
||||
if (!is_file($textPath)) {
|
||||
$textPath = $base . '/' . $template . '.txt';
|
||||
}
|
||||
if (!is_file($htmlHeaderPath)) {
|
||||
$htmlHeaderPath = $base . '/partials/header.html';
|
||||
}
|
||||
if (!is_file($htmlFooterPath)) {
|
||||
$htmlFooterPath = $base . '/partials/footer.html';
|
||||
}
|
||||
if (!is_file($textHeaderPath)) {
|
||||
$textHeaderPath = $base . '/partials/header.txt';
|
||||
}
|
||||
if (!is_file($textFooterPath)) {
|
||||
$textFooterPath = $base . '/partials/footer.txt';
|
||||
}
|
||||
|
||||
$html = is_file($htmlPath) ? file_get_contents($htmlPath) : '';
|
||||
$text = is_file($textPath) ? file_get_contents($textPath) : '';
|
||||
if (!isset($vars['email_header'])) {
|
||||
@@ -138,16 +126,18 @@ class MailService
|
||||
$mailer = new PHPMailer(true);
|
||||
$mailer->isSMTP();
|
||||
|
||||
$host = $this->settingGateway->getSmtpHost() ?? (getenv('SMTP_HOST') ?: '');
|
||||
$port = $this->settingGateway->getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587);
|
||||
$user = $this->settingGateway->getSmtpUser() ?? (getenv('SMTP_USER') ?: '');
|
||||
$pass = $this->settingGateway->getSmtpPassword() ?? (getenv('SMTP_PASS') ?: '');
|
||||
$secure = $this->settingGateway->getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls'));
|
||||
$from = $this->settingGateway->getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost'));
|
||||
$fromName = $this->settingGateway->getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle());
|
||||
// DB setting takes priority; env var fallback allows Docker-compose-style configuration without a DB record.
|
||||
$host = $this->settingsSmtpGateway->getSmtpHost() ?? (getenv('SMTP_HOST') ?: '');
|
||||
$port = $this->settingsSmtpGateway->getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587);
|
||||
$user = $this->settingsSmtpGateway->getSmtpUser() ?? (getenv('SMTP_USER') ?: '');
|
||||
$pass = $this->settingsSmtpGateway->getSmtpPassword() ?? (getenv('SMTP_PASS') ?: '');
|
||||
$secure = $this->settingsSmtpGateway->getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls'));
|
||||
$from = $this->settingsSmtpGateway->getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost'));
|
||||
$fromName = $this->settingsSmtpGateway->getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle());
|
||||
|
||||
$mailer->Host = $host;
|
||||
$mailer->Port = $port;
|
||||
// Enable SMTP auth only if credentials are configured — supports relay servers without auth.
|
||||
$mailer->SMTPAuth = $user !== '' || $pass !== '';
|
||||
$mailer->Username = $user;
|
||||
$mailer->Password = $pass;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace MintyPHP\Service\Mail;
|
||||
|
||||
use MintyPHP\Repository\Mail\MailLogRepositoryInterface;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
|
||||
class MailServicesFactory
|
||||
{
|
||||
@@ -12,7 +12,7 @@ class MailServicesFactory
|
||||
|
||||
public function __construct(
|
||||
private readonly MailRepositoryFactory $mailRepositoryFactory,
|
||||
private readonly SettingGateway $settingGateway
|
||||
private readonly SettingsSmtpGateway $settingsSmtpGateway
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class MailServicesFactory
|
||||
{
|
||||
return $this->mailService ??= new MailService(
|
||||
$this->createMailLogRepository(),
|
||||
$this->createSettingGateway()
|
||||
$this->createSettingsSmtpGateway()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ class MailServicesFactory
|
||||
return $this->mailLogService ??= new MailLogService($this->createMailLogRepository());
|
||||
}
|
||||
|
||||
private function createSettingGateway(): SettingGateway
|
||||
private function createSettingsSmtpGateway(): SettingsSmtpGateway
|
||||
{
|
||||
return $this->settingGateway;
|
||||
return $this->settingsSmtpGateway;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class DepartmentService
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
// Global-scope users see all departments — remove tenant filter so the query isn't restricted.
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0 && $this->scopeGateway->hasGlobalAccess($tenantUserId)) {
|
||||
@@ -78,6 +79,8 @@ class DepartmentService
|
||||
return $this->departmentRepository->listByIds($departmentIds);
|
||||
}
|
||||
|
||||
// Merges the tenant-scoped list with any already-selected departments outside the filter —
|
||||
// ensures a user keeps visibility of departments they're already assigned to.
|
||||
public function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array
|
||||
{
|
||||
$departments = $tenantIds ? $this->listByTenantIds($tenantIds) : $this->list();
|
||||
@@ -192,6 +195,8 @@ class DepartmentService
|
||||
return ['ok' => true, 'form' => $form, 'warnings' => $warnings];
|
||||
}
|
||||
|
||||
// After moving a department to a different tenant, users who aren't in the new tenant
|
||||
// are no longer valid members — remove their assignments immediately.
|
||||
public function syncTenant(int $departmentId, int $tenantId): int
|
||||
{
|
||||
$updated = $this->departmentRepository->setTenant($departmentId, $tenantId);
|
||||
@@ -201,6 +206,7 @@ class DepartmentService
|
||||
return $this->cleanupUserAssignments($departmentId);
|
||||
}
|
||||
|
||||
// Departments belong to exactly one tenant — only the first ID in the array is used.
|
||||
public function syncTenants(int $departmentId, array $tenantIds): int
|
||||
{
|
||||
$tenantId = (int) ($tenantIds[0] ?? 0);
|
||||
@@ -266,6 +272,8 @@ class DepartmentService
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// Duplicate codes are a warning, not a hard error — codes are optional identifiers,
|
||||
// not unique keys, so duplicates are allowed but surfaced to the user.
|
||||
private function warningsForCode(array $form, int $excludeId): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
@@ -17,6 +17,8 @@ class ScheduledJobService
|
||||
) {
|
||||
}
|
||||
|
||||
// Syncs the registry definitions to the DB on every request that needs job data —
|
||||
// new jobs are inserted automatically without a separate migration step.
|
||||
public function ensureSystemJobs(): void
|
||||
{
|
||||
$definitions = $this->scheduledJobRegistry->definitions();
|
||||
@@ -217,6 +219,7 @@ class ScheduledJobService
|
||||
{
|
||||
$type = $this->scheduleCalculator->normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily')));
|
||||
$normalized = [
|
||||
// label and description always come from the registry — admin edits to those fields are not preserved.
|
||||
'label' => (string) ($definition['label'] ?? (string) ($existing['label'] ?? '')),
|
||||
'description' => (string) ($definition['description'] ?? (string) ($existing['description'] ?? '')),
|
||||
'enabled' => (int) ($existing['enabled'] ?? ($definition['default_enabled'] ?? 1)),
|
||||
|
||||
@@ -160,6 +160,7 @@ class SchedulerRunService
|
||||
return $run;
|
||||
}
|
||||
|
||||
// markRunning uses a conditional update — failure means another process already claimed this job.
|
||||
$markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc);
|
||||
if (!$markedRunning) {
|
||||
$this->insertRunLog([
|
||||
@@ -268,6 +269,7 @@ class SchedulerRunService
|
||||
return $run;
|
||||
}
|
||||
|
||||
// Fail-open: a logging failure must not kill the job run that already completed.
|
||||
private function insertRunLog(array $data): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -98,6 +98,9 @@ class RateLimiterService
|
||||
$nowTs = time();
|
||||
$nowSql = gmdate('Y-m-d H:i:s', $nowTs);
|
||||
|
||||
// Two-attempt loop handles a creation race: if two requests arrive simultaneously for a
|
||||
// new subject, both may find no row and try to INSERT. The second INSERT fails (unique key),
|
||||
// so we retry once and the second attempt reads the now-existing row.
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
|
||||
|
||||
@@ -136,6 +139,7 @@ class RateLimiterService
|
||||
}
|
||||
|
||||
$windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? ''));
|
||||
// Sliding window expired — reset the window start and hit count.
|
||||
if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) {
|
||||
$windowStartedTs = $nowTs;
|
||||
$hits = 0;
|
||||
@@ -189,6 +193,7 @@ class RateLimiterService
|
||||
$scope = substr($scope, 0, 64);
|
||||
}
|
||||
|
||||
// Hash the subject (e.g. email or IP) so PII is never stored in the rate limit table.
|
||||
return [
|
||||
'scope' => $scope,
|
||||
'subject_hash' => hash(self::HASH_ALGO, $subject),
|
||||
|
||||
@@ -12,7 +12,15 @@ use MintyPHP\Service\Tenant\TenantService;
|
||||
class AdminSettingsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingGateway $settingGateway,
|
||||
private readonly SettingsDefaultsGateway $settingsDefaultsGateway,
|
||||
private readonly SettingsAppGateway $settingsAppGateway,
|
||||
private readonly SettingsApiPolicyGateway $settingsApiPolicyGateway,
|
||||
private readonly SettingsUserLifecycleGateway $settingsUserLifecycleGateway,
|
||||
private readonly SettingsSystemAuditGateway $settingsSystemAuditGateway,
|
||||
private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway,
|
||||
private readonly SettingsMicrosoftGateway $settingsMicrosoftGateway,
|
||||
private readonly SettingsSmtpGateway $settingsSmtpGateway,
|
||||
private readonly SettingsMetadataGateway $settingsMetadataGateway,
|
||||
private readonly SettingCacheService $settingCacheService,
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly RoleService $roleService,
|
||||
@@ -40,58 +48,64 @@ class AdminSettingsService
|
||||
'roles' => $roles,
|
||||
'departments' => $departments,
|
||||
'values' => [
|
||||
'default_tenant_id' => $this->settingGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingGateway->getDefaultDepartmentId(),
|
||||
'app_title' => $this->settingGateway->getValue(SettingService::APP_TITLE_KEY),
|
||||
'app_locale' => $this->settingGateway->getValue(SettingService::APP_LOCALE_KEY),
|
||||
'app_theme' => $this->settingGateway->getValue(SettingService::APP_THEME_KEY),
|
||||
'app_theme_user' => $this->settingGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingGateway->getValue(SettingService::APP_PRIMARY_COLOR_KEY),
|
||||
'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(),
|
||||
'api_cors_allowed_origins' => $this->settingGateway->getApiCorsAllowedOriginsText(),
|
||||
'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(),
|
||||
'microsoft_shared_client_id' => $this->settingGateway->getValue(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY),
|
||||
'microsoft_authority' => $this->settingGateway->getMicrosoftAuthority(),
|
||||
'smtp_host' => $this->settingGateway->getValue(SettingService::SMTP_HOST_KEY),
|
||||
'smtp_port' => $this->settingGateway->getValue(SettingService::SMTP_PORT_KEY),
|
||||
'smtp_user' => $this->settingGateway->getValue(SettingService::SMTP_USER_KEY),
|
||||
'smtp_secure' => $this->settingGateway->getSmtpSecure(),
|
||||
'smtp_from' => $this->settingGateway->getValue(SettingService::SMTP_FROM_KEY),
|
||||
'smtp_from_name' => $this->settingGateway->getValue(SettingService::SMTP_FROM_NAME_KEY),
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
'app_title' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_TITLE_KEY),
|
||||
'app_locale' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_LOCALE_KEY),
|
||||
'app_theme' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_KEY),
|
||||
'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingsMetadataGateway->getValue(SettingKeys::APP_PRIMARY_COLOR_KEY),
|
||||
'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(),
|
||||
'api_cors_allowed_origins' => $this->settingsApiPolicyGateway->getApiCorsAllowedOriginsText(),
|
||||
'system_audit_enabled' => $this->settingsSystemAuditGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingsSystemAuditGateway->getSystemAuditRetentionDays(),
|
||||
'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled(),
|
||||
'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(),
|
||||
'microsoft_shared_client_id' => $this->settingsMetadataGateway->getValue(SettingKeys::MICROSOFT_SHARED_CLIENT_ID_KEY),
|
||||
'microsoft_authority' => $this->settingsMicrosoftGateway->getMicrosoftAuthority(),
|
||||
'smtp_host' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_HOST_KEY),
|
||||
'smtp_port' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_PORT_KEY),
|
||||
'smtp_user' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_USER_KEY),
|
||||
'smtp_secure' => $this->settingsSmtpGateway->getSmtpSecure(),
|
||||
'smtp_from' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_FROM_KEY),
|
||||
'smtp_from_name' => $this->settingsMetadataGateway->getValue(SettingKeys::SMTP_FROM_NAME_KEY),
|
||||
],
|
||||
'settings' => [
|
||||
'default_tenant' => $this->settingGateway->get(SettingService::DEFAULT_TENANT_KEY),
|
||||
'default_role' => $this->settingGateway->get(SettingService::DEFAULT_ROLE_KEY),
|
||||
'default_department' => $this->settingGateway->get(SettingService::DEFAULT_DEPARTMENT_KEY),
|
||||
'app_title' => $this->settingGateway->get(SettingService::APP_TITLE_KEY),
|
||||
'app_locale' => $this->settingGateway->get(SettingService::APP_LOCALE_KEY),
|
||||
'app_theme' => $this->settingGateway->get(SettingService::APP_THEME_KEY),
|
||||
'app_theme_user' => $this->settingGateway->get(SettingService::APP_THEME_USER_KEY),
|
||||
'app_registration' => $this->settingGateway->get(SettingService::APP_REGISTRATION_KEY),
|
||||
'app_primary_color' => $this->settingGateway->get(SettingService::APP_PRIMARY_COLOR_KEY),
|
||||
'api_token_default_ttl_days' => $this->settingGateway->get(SettingService::API_TOKEN_DEFAULT_TTL_DAYS_KEY),
|
||||
'api_token_max_ttl_days' => $this->settingGateway->get(SettingService::API_TOKEN_MAX_TTL_DAYS_KEY),
|
||||
'user_inactivity_deactivate_days' => $this->settingGateway->get(SettingService::USER_INACTIVITY_DEACTIVATE_DAYS_KEY),
|
||||
'user_inactivity_delete_days' => $this->settingGateway->get(SettingService::USER_INACTIVITY_DELETE_DAYS_KEY),
|
||||
'api_cors_allowed_origins' => $this->settingGateway->get(SettingService::API_CORS_ALLOWED_ORIGINS_KEY),
|
||||
'system_audit_enabled' => $this->settingGateway->get(SettingService::SYSTEM_AUDIT_ENABLED_KEY),
|
||||
'system_audit_retention_days' => $this->settingGateway->get(SettingService::SYSTEM_AUDIT_RETENTION_DAYS_KEY),
|
||||
'microsoft_shared_client_id' => $this->settingGateway->get(SettingService::MICROSOFT_SHARED_CLIENT_ID_KEY),
|
||||
'microsoft_shared_client_secret_enc' => $this->settingGateway->get(SettingService::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY),
|
||||
'microsoft_authority' => $this->settingGateway->get(SettingService::MICROSOFT_AUTHORITY_KEY),
|
||||
'smtp_host' => $this->settingGateway->get(SettingService::SMTP_HOST_KEY),
|
||||
'smtp_port' => $this->settingGateway->get(SettingService::SMTP_PORT_KEY),
|
||||
'smtp_user' => $this->settingGateway->get(SettingService::SMTP_USER_KEY),
|
||||
'smtp_password' => $this->settingGateway->get(SettingService::SMTP_PASSWORD_KEY),
|
||||
'smtp_secure' => $this->settingGateway->get(SettingService::SMTP_SECURE_KEY),
|
||||
'smtp_from' => $this->settingGateway->get(SettingService::SMTP_FROM_KEY),
|
||||
'smtp_from_name' => $this->settingGateway->get(SettingService::SMTP_FROM_NAME_KEY),
|
||||
'default_tenant' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_TENANT_KEY),
|
||||
'default_role' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_ROLE_KEY),
|
||||
'default_department' => $this->settingsMetadataGateway->get(SettingKeys::DEFAULT_DEPARTMENT_KEY),
|
||||
'app_title' => $this->settingsMetadataGateway->get(SettingKeys::APP_TITLE_KEY),
|
||||
'app_locale' => $this->settingsMetadataGateway->get(SettingKeys::APP_LOCALE_KEY),
|
||||
'app_theme' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_KEY),
|
||||
'app_theme_user' => $this->settingsMetadataGateway->get(SettingKeys::APP_THEME_USER_KEY),
|
||||
'app_registration' => $this->settingsMetadataGateway->get(SettingKeys::APP_REGISTRATION_KEY),
|
||||
'app_primary_color' => $this->settingsMetadataGateway->get(SettingKeys::APP_PRIMARY_COLOR_KEY),
|
||||
'api_token_default_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_DEFAULT_TTL_DAYS_KEY),
|
||||
'api_token_max_ttl_days' => $this->settingsMetadataGateway->get(SettingKeys::API_TOKEN_MAX_TTL_DAYS_KEY),
|
||||
'user_inactivity_deactivate_days' => $this->settingsMetadataGateway->get(SettingKeys::USER_INACTIVITY_DEACTIVATE_DAYS_KEY),
|
||||
'user_inactivity_delete_days' => $this->settingsMetadataGateway->get(SettingKeys::USER_INACTIVITY_DELETE_DAYS_KEY),
|
||||
'api_cors_allowed_origins' => $this->settingsMetadataGateway->get(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY),
|
||||
'system_audit_enabled' => $this->settingsMetadataGateway->get(SettingKeys::SYSTEM_AUDIT_ENABLED_KEY),
|
||||
'system_audit_retention_days' => $this->settingsMetadataGateway->get(SettingKeys::SYSTEM_AUDIT_RETENTION_DAYS_KEY),
|
||||
'frontend_telemetry_enabled' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_ENABLED_KEY),
|
||||
'frontend_telemetry_sample_rate' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_SAMPLE_RATE_KEY),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsMetadataGateway->get(SettingKeys::FRONTEND_TELEMETRY_ALLOWED_EVENTS_KEY),
|
||||
'microsoft_shared_client_id' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_SHARED_CLIENT_ID_KEY),
|
||||
'microsoft_shared_client_secret_enc' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY),
|
||||
'microsoft_authority' => $this->settingsMetadataGateway->get(SettingKeys::MICROSOFT_AUTHORITY_KEY),
|
||||
'smtp_host' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_HOST_KEY),
|
||||
'smtp_port' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_PORT_KEY),
|
||||
'smtp_user' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_USER_KEY),
|
||||
'smtp_password' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_PASSWORD_KEY),
|
||||
'smtp_secure' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_SECURE_KEY),
|
||||
'smtp_from' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_FROM_KEY),
|
||||
'smtp_from_name' => $this->settingsMetadataGateway->get(SettingKeys::SMTP_FROM_NAME_KEY),
|
||||
],
|
||||
'active_login_tokens' => $this->rememberTokenRepository->countActive(),
|
||||
'active_api_tokens' => $this->apiTokenRepository->countActive(),
|
||||
@@ -115,6 +129,9 @@ class AdminSettingsService
|
||||
$apiCorsAllowedOrigins = $this->normalizeScalarText($post['api_cors_allowed_origins'] ?? '');
|
||||
$systemAuditEnabled = isset($post['system_audit_enabled']);
|
||||
$systemAuditRetentionDays = (int) ($post['system_audit_retention_days'] ?? 0);
|
||||
$frontendTelemetryEnabled = isset($post['frontend_telemetry_enabled']);
|
||||
$frontendTelemetrySampleRate = $this->normalizeScalarText($post['frontend_telemetry_sample_rate'] ?? '');
|
||||
$frontendTelemetryAllowedEvents = $post['frontend_telemetry_allowed_events'] ?? [];
|
||||
$appPrimaryColor = $this->normalizePrimaryColor($post['app_primary_color'] ?? '');
|
||||
$smtpHost = trim((string) ($post['smtp_host'] ?? ''));
|
||||
$smtpPort = (int) ($post['smtp_port'] ?? 0);
|
||||
@@ -129,41 +146,44 @@ class AdminSettingsService
|
||||
|
||||
$errors = [];
|
||||
$beforeAudit = [
|
||||
'default_tenant_id' => $this->settingGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingGateway->getDefaultDepartmentId(),
|
||||
'app_locale' => $this->settingGateway->getAppLocale(),
|
||||
'app_theme' => $this->settingGateway->getAppTheme(),
|
||||
'app_theme_user' => $this->settingGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingGateway->getAppPrimaryColor(),
|
||||
'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(),
|
||||
'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(),
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
'app_locale' => $this->settingsAppGateway->getAppLocale(),
|
||||
'app_theme' => $this->settingsAppGateway->getAppTheme(),
|
||||
'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingsAppGateway->getAppPrimaryColor(),
|
||||
'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(),
|
||||
'system_audit_enabled' => $this->settingsSystemAuditGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingsSystemAuditGateway->getSystemAuditRetentionDays(),
|
||||
'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled(),
|
||||
'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(),
|
||||
];
|
||||
|
||||
$this->settingGateway->setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null);
|
||||
$this->settingGateway->setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null);
|
||||
$this->settingGateway->setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null);
|
||||
$this->settingGateway->setAppTitle($appTitle);
|
||||
$this->settingGateway->setAppLocale($appLocale);
|
||||
$this->settingGateway->setAppTheme($appTheme);
|
||||
$this->settingGateway->setUserThemeAllowed($appThemeUser);
|
||||
$this->settingGateway->setRegistrationEnabled($appRegistration);
|
||||
$this->settingsDefaultsGateway->setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null);
|
||||
$this->settingsDefaultsGateway->setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null);
|
||||
$this->settingsDefaultsGateway->setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null);
|
||||
$this->settingsAppGateway->setAppTitle($appTitle);
|
||||
$this->settingsAppGateway->setAppLocale($appLocale);
|
||||
$this->settingsAppGateway->setAppTheme($appTheme);
|
||||
$this->settingsAppGateway->setUserThemeAllowed($appThemeUser);
|
||||
$this->settingsAppGateway->setRegistrationEnabled($appRegistration);
|
||||
|
||||
$apiTokenMaxTtlOk = $this->settingGateway->setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null);
|
||||
$apiTokenMaxTtlOk = $this->settingsApiPolicyGateway->setApiTokenMaxTtlDays($apiTokenMaxTtlDays > 0 ? $apiTokenMaxTtlDays : null);
|
||||
if (!$apiTokenMaxTtlOk) {
|
||||
$errors[] = ['message' => 'API token max lifetime is invalid', 'key' => 'api_token_max_ttl_invalid'];
|
||||
}
|
||||
$apiTokenTtlOk = $this->settingGateway->setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null);
|
||||
$apiTokenTtlOk = $this->settingsApiPolicyGateway->setApiTokenDefaultTtlDays($apiTokenDefaultTtlDays > 0 ? $apiTokenDefaultTtlDays : null);
|
||||
if (!$apiTokenTtlOk) {
|
||||
$errors[] = ['message' => 'API token default lifetime is invalid', 'key' => 'api_token_default_ttl_invalid'];
|
||||
}
|
||||
|
||||
$userDeactivateOk = $this->settingGateway->setUserInactivityDeactivateDays($userInactivityDeactivateDays);
|
||||
$userDeactivateOk = $this->settingsUserLifecycleGateway->setUserInactivityDeactivateDays($userInactivityDeactivateDays);
|
||||
if (!$userDeactivateOk) {
|
||||
$errors[] = [
|
||||
'message' => 'User inactivity deactivation period is invalid',
|
||||
@@ -172,7 +192,7 @@ class AdminSettingsService
|
||||
}
|
||||
|
||||
if ($userInactivityDeactivateDays > 0) {
|
||||
$userDeleteOk = $this->settingGateway->setUserInactivityDeleteDays($userInactivityDeleteDays);
|
||||
$userDeleteOk = $this->settingsUserLifecycleGateway->setUserInactivityDeleteDays($userInactivityDeleteDays);
|
||||
if (!$userDeleteOk) {
|
||||
$errors[] = [
|
||||
'message' => 'User inactivity deletion period is invalid',
|
||||
@@ -186,56 +206,76 @@ class AdminSettingsService
|
||||
'key' => 'user_inactivity_delete_requires_deactivate',
|
||||
];
|
||||
}
|
||||
$this->settingGateway->setUserInactivityDeleteDays(0);
|
||||
$this->settingsUserLifecycleGateway->setUserInactivityDeleteDays(0);
|
||||
}
|
||||
|
||||
$apiCorsOriginsOk = $this->settingGateway->setApiCorsAllowedOrigins($apiCorsAllowedOrigins);
|
||||
$apiCorsOriginsOk = $this->settingsApiPolicyGateway->setApiCorsAllowedOrigins($apiCorsAllowedOrigins);
|
||||
if (!$apiCorsOriginsOk) {
|
||||
$errors[] = ['message' => 'CORS allowed origins are invalid', 'key' => 'api_cors_allowed_origins_invalid'];
|
||||
}
|
||||
|
||||
$systemAuditEnabledOk = $this->settingGateway->setSystemAuditEnabled($systemAuditEnabled);
|
||||
$systemAuditEnabledOk = $this->settingsSystemAuditGateway->setSystemAuditEnabled($systemAuditEnabled);
|
||||
if (!$systemAuditEnabledOk) {
|
||||
$errors[] = ['message' => 'System audit setting is invalid', 'key' => 'system_audit_enabled_invalid'];
|
||||
}
|
||||
|
||||
$systemAuditRetentionOk = $this->settingGateway->setSystemAuditRetentionDays(
|
||||
$systemAuditRetentionOk = $this->settingsSystemAuditGateway->setSystemAuditRetentionDays(
|
||||
$systemAuditRetentionDays > 0 ? $systemAuditRetentionDays : null
|
||||
);
|
||||
if (!$systemAuditRetentionOk) {
|
||||
$errors[] = ['message' => 'System audit retention is invalid', 'key' => 'system_audit_retention_invalid'];
|
||||
}
|
||||
|
||||
$primaryOk = $this->settingGateway->setAppPrimaryColor($appPrimaryColor);
|
||||
$frontendTelemetryEnabledOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryEnabled($frontendTelemetryEnabled);
|
||||
if (!$frontendTelemetryEnabledOk) {
|
||||
$errors[] = ['message' => 'Frontend telemetry enabled setting is invalid', 'key' => 'frontend_telemetry_enabled_invalid'];
|
||||
}
|
||||
|
||||
$frontendTelemetryRateValue = null;
|
||||
if ($frontendTelemetrySampleRate !== '') {
|
||||
$frontendTelemetryRateValue = is_numeric($frontendTelemetrySampleRate)
|
||||
? (float) $frontendTelemetrySampleRate
|
||||
: NAN;
|
||||
}
|
||||
$frontendTelemetrySampleRateOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetrySampleRate($frontendTelemetryRateValue);
|
||||
if (!$frontendTelemetrySampleRateOk) {
|
||||
$errors[] = ['message' => 'Frontend telemetry sample rate is invalid', 'key' => 'frontend_telemetry_sample_rate_invalid'];
|
||||
}
|
||||
|
||||
$frontendTelemetryAllowedEventsOk = $this->settingsFrontendTelemetryGateway->setFrontendTelemetryAllowedEvents($frontendTelemetryAllowedEvents);
|
||||
if (!$frontendTelemetryAllowedEventsOk) {
|
||||
$errors[] = ['message' => 'Frontend telemetry allowed events are invalid', 'key' => 'frontend_telemetry_allowed_events_invalid'];
|
||||
}
|
||||
|
||||
$primaryOk = $this->settingsAppGateway->setAppPrimaryColor($appPrimaryColor);
|
||||
if (!$primaryOk) {
|
||||
$appPrimaryColor = '';
|
||||
$errors[] = ['message' => 'Primary color is invalid', 'key' => 'app_primary_invalid'];
|
||||
}
|
||||
|
||||
$this->settingGateway->setSmtpHost($smtpHost);
|
||||
$this->settingGateway->setSmtpPort($smtpPort > 0 ? $smtpPort : null);
|
||||
$this->settingGateway->setSmtpUser($smtpUser);
|
||||
$this->settingsSmtpGateway->setSmtpHost($smtpHost);
|
||||
$this->settingsSmtpGateway->setSmtpPort($smtpPort > 0 ? $smtpPort : null);
|
||||
$this->settingsSmtpGateway->setSmtpUser($smtpUser);
|
||||
if (trim($smtpPassword) !== '') {
|
||||
$this->settingGateway->setSmtpPassword($smtpPassword);
|
||||
$this->settingsSmtpGateway->setSmtpPassword($smtpPassword);
|
||||
}
|
||||
$this->settingGateway->setSmtpSecure($smtpSecure);
|
||||
$this->settingGateway->setSmtpFrom($smtpFrom);
|
||||
$this->settingGateway->setSmtpFromName($smtpFromName);
|
||||
$this->settingsSmtpGateway->setSmtpSecure($smtpSecure);
|
||||
$this->settingsSmtpGateway->setSmtpFrom($smtpFrom);
|
||||
$this->settingsSmtpGateway->setSmtpFromName($smtpFromName);
|
||||
|
||||
$msClientIdOk = $this->settingGateway->setMicrosoftSharedClientId($microsoftSharedClientId);
|
||||
$msClientIdOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientId($microsoftSharedClientId);
|
||||
if (!$msClientIdOk) {
|
||||
$errors[] = ['message' => 'Microsoft client ID is invalid', 'key' => 'microsoft_client_id_invalid'];
|
||||
}
|
||||
|
||||
$msAuthorityOk = $this->settingGateway->setMicrosoftAuthority($microsoftAuthority);
|
||||
$msAuthorityOk = $this->settingsMicrosoftGateway->setMicrosoftAuthority($microsoftAuthority);
|
||||
if (!$msAuthorityOk) {
|
||||
$errors[] = ['message' => 'Microsoft authority is invalid', 'key' => 'microsoft_authority_invalid'];
|
||||
}
|
||||
|
||||
if (trim($microsoftSharedClientSecret) !== '') {
|
||||
try {
|
||||
$this->settingGateway->setMicrosoftSharedClientSecret($microsoftSharedClientSecret);
|
||||
} catch (\Throwable) {
|
||||
$msSecretOk = $this->settingsMicrosoftGateway->setMicrosoftSharedClientSecret($microsoftSharedClientSecret);
|
||||
if (!$msSecretOk) {
|
||||
$errors[] = [
|
||||
'message' => 'Microsoft client secret could not be encrypted',
|
||||
'key' => 'microsoft_client_secret_invalid',
|
||||
@@ -250,26 +290,32 @@ class AdminSettingsService
|
||||
'app_theme_user' => $appThemeUser ? '1' : '0',
|
||||
'app_registration' => $appRegistration ? '1' : '0',
|
||||
'app_primary_color' => $appPrimaryColor !== '' ? $appPrimaryColor : null,
|
||||
'api_token_default_ttl_days' => (string) $this->settingGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => (string) $this->settingGateway->getApiTokenMaxTtlDays(),
|
||||
'api_cors_allowed_origins' => $this->settingGateway->getValue(SettingService::API_CORS_ALLOWED_ORIGINS_KEY),
|
||||
'api_token_default_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => (string) $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'api_cors_allowed_origins' => $this->settingsMetadataGateway->getValue(SettingKeys::API_CORS_ALLOWED_ORIGINS_KEY),
|
||||
'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled() ? '1' : '0',
|
||||
'frontend_telemetry_sample_rate' => (string) $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => implode(',', $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents()),
|
||||
]);
|
||||
|
||||
$afterAudit = [
|
||||
'default_tenant_id' => $this->settingGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingGateway->getDefaultDepartmentId(),
|
||||
'app_locale' => $this->settingGateway->getAppLocale(),
|
||||
'app_theme' => $this->settingGateway->getAppTheme(),
|
||||
'app_theme_user' => $this->settingGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingGateway->getAppPrimaryColor(),
|
||||
'api_token_default_ttl_days' => $this->settingGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingGateway->getUserInactivityDeleteDays(),
|
||||
'system_audit_enabled' => $this->settingGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingGateway->getSystemAuditRetentionDays(),
|
||||
'default_tenant_id' => $this->settingsDefaultsGateway->getDefaultTenantId(),
|
||||
'default_role_id' => $this->settingsDefaultsGateway->getDefaultRoleId(),
|
||||
'default_department_id' => $this->settingsDefaultsGateway->getDefaultDepartmentId(),
|
||||
'app_locale' => $this->settingsAppGateway->getAppLocale(),
|
||||
'app_theme' => $this->settingsAppGateway->getAppTheme(),
|
||||
'app_theme_user' => $this->settingsAppGateway->isUserThemeAllowed(),
|
||||
'app_registration' => $this->settingsAppGateway->isRegistrationEnabled(),
|
||||
'app_primary_color' => $this->settingsAppGateway->getAppPrimaryColor(),
|
||||
'api_token_default_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenDefaultTtlDays(),
|
||||
'api_token_max_ttl_days' => $this->settingsApiPolicyGateway->getApiTokenMaxTtlDays(),
|
||||
'user_inactivity_deactivate_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays(),
|
||||
'user_inactivity_delete_days' => $this->settingsUserLifecycleGateway->getUserInactivityDeleteDays(),
|
||||
'system_audit_enabled' => $this->settingsSystemAuditGateway->isSystemAuditEnabled(),
|
||||
'system_audit_retention_days' => $this->settingsSystemAuditGateway->getSystemAuditRetentionDays(),
|
||||
'frontend_telemetry_enabled' => $this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled(),
|
||||
'frontend_telemetry_sample_rate' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate(),
|
||||
'frontend_telemetry_allowed_events' => $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(),
|
||||
];
|
||||
|
||||
$outcome = $errors === [] ? 'success' : 'failed';
|
||||
|
||||
@@ -61,11 +61,14 @@ class SettingCacheService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write to a temp file first, then rename — rename is atomic on most filesystems,
|
||||
// so readers never see a half-written file. LOCK_EX prevents concurrent write corruption.
|
||||
$tmp = tempnam($dir, 'settings_');
|
||||
if ($tmp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// PHP include returns an array — loaded by OPcache on subsequent requests, much faster than DB.
|
||||
$payload = "<?php\nreturn " . $this->exportValue($settings) . ";\n";
|
||||
$written = @file_put_contents($tmp, $payload, LOCK_EX);
|
||||
if ($written === false) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user