agent foundation

This commit is contained in:
2026-03-06 00:44:52 +01:00
parent 9819cba733
commit 9a08f96c11
199 changed files with 8522 additions and 1880 deletions

14
agent-system/README.md Normal file
View 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`

View 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

View 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"
}
]
}

View 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`

View 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."
}
]
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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

View 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`

View 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

View 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`

View 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

View File

@@ -0,0 +1 @@

View 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": []
}

View 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": ""
}

View 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"
}
]
}

View 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": []
}

View 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": []
}

View File

@@ -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',

View File

@@ -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',
];

View File

@@ -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`;

View 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
View 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
View 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
View 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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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)",

View File

@@ -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)",

View File

@@ -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];
}

View File

@@ -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) {

View File

@@ -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());

View File

@@ -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));
}
}

View File

@@ -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());

View File

@@ -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)
));
}
}

View File

@@ -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());
}

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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 === []) {

View File

@@ -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
View 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]);
}
}

View 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;
}

View File

@@ -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);
}

View File

@@ -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 !== '') {

View File

@@ -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'] ?? ''));

View 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();
}
}

View 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
View 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;
}
}

View 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;
}

View File

@@ -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
);
}

View File

@@ -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([

View File

@@ -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(

View File

@@ -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;

View File

@@ -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');

View File

@@ -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)) {

View File

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

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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
);
}
}

View 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;
}
}

View File

@@ -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')

View File

@@ -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

View File

@@ -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 [

View File

@@ -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();
}
}

View File

@@ -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;
/**
* @return array<string, mixed>
*/
private function sessionCurrentTenant(): array
{
$tenant = $this->sessionStore->get('current_tenant', []);
return is_array($tenant) ? $tenant : [];
}
$_SESSION['no_active_tenant'] = false;
// 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);
}
}

View File

@@ -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
);
}
}

View 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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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'] ?? ''));

View File

@@ -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 : [];
}

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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!';

View File

@@ -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(

View File

@@ -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';
}

View File

@@ -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 . '.*');

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -19,7 +19,7 @@ class DirectoryGatewayFactory
public function createDirectorySettingsGateway(): DirectorySettingsGateway
{
return $this->directorySettingsGateway ??= new DirectorySettingsGateway(
$this->settingServicesFactory->createSettingGateway()
$this->settingServicesFactory->createSettingsDefaultsGateway()
);
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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 = [];

View File

@@ -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)),

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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';

View File

@@ -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