From 16759a2732ec18fdea94a164ab4657a60374187d Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 24 Feb 2026 08:49:40 +0100 Subject: [PATCH] =?UTF-8?q?weitere=20updates=20f=C3=BCr=20instanzierbarkei?= =?UTF-8?q?t=20und=20sauberes=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 120 ++++ db/init/init.sql | 3 +- db/updates/2026-02-23-tenant-scope-global.sql | 15 + docs/anfragelimits.md | 24 +- docs/architektur.md | 231 ++----- docs/entwickler-checkliste.md | 3 +- docs/erste-aenderung.md | 2 +- docs/fehlerbehebung.md | 11 +- docs/index.md | 8 +- docs/konventionen.md | 144 +---- docs/lib-standards.md | 39 ++ docs/lokale-entwicklung.md | 2 +- docs/openapi.yaml | 601 +++++++++++++++++- docs/sicherheitsmodell.md | 13 +- i18n/default_de.json | 1 + i18n/default_en.json | 1 + lib/Http/ApiBootstrap.php | 12 +- lib/Service/Access/AccessServicesFactory.php | 78 +++ lib/Service/Access/AuthorizationDecision.php | 50 ++ .../Access/AuthorizationPolicyInterface.php | 11 + lib/Service/Access/AuthorizationService.php | 41 ++ .../Access/DepartmentAuthorizationPolicy.php | 160 +++++ .../Access/PermissionAuthorizationPolicy.php | 129 ++++ lib/Service/Access/PermissionService.php | 1 + .../Access/RoleAuthorizationPolicy.php | 127 ++++ .../Access/SettingsAuthorizationPolicy.php | 75 +++ .../Access/TenantAuthorizationPolicy.php | 233 +++++++ .../Access/UserAuthorizationPolicy.php | 455 +++++++++++++ lib/Service/Tenant/TenantScopeService.php | 3 +- lib/Service/User/UserAccountService.php | 98 +++ pages/admin/departments/create().php | 19 +- pages/admin/departments/data().php | 14 +- pages/admin/departments/delete($id).php | 22 +- pages/admin/departments/edit($id).php | 52 +- pages/admin/departments/index().php | 19 +- pages/admin/departments/index(default).phtml | 2 +- pages/admin/permissions/create().php | 13 +- pages/admin/permissions/data().php | 15 +- pages/admin/permissions/delete($id).php | 14 +- pages/admin/permissions/edit($id).php | 33 +- pages/admin/permissions/edit(default).phtml | 11 +- pages/admin/permissions/index().php | 15 +- pages/admin/permissions/index(default).phtml | 3 +- pages/admin/roles/create().php | 15 +- pages/admin/roles/data().php | 15 +- pages/admin/roles/delete($id).php | 14 +- pages/admin/roles/edit($id).php | 28 +- pages/admin/roles/edit(default).phtml | 10 +- pages/admin/roles/index().php | 15 +- pages/admin/roles/index(default).phtml | 2 +- .../settings/expire-remember-tokens().php | 13 +- pages/admin/settings/favicon().php | 13 +- pages/admin/settings/favicon-delete().php | 13 +- pages/admin/settings/index().php | 22 +- pages/admin/settings/index(default).phtml | 3 +- pages/admin/settings/logo().php | 13 +- pages/admin/settings/logo-delete().php | 13 +- pages/admin/settings/revoke-api-tokens().php | 13 +- pages/admin/settings/run-user-lifecycle().php | 15 +- pages/admin/tenants/avatar($id).php | 17 +- pages/admin/tenants/avatar-delete($id).php | 17 +- pages/admin/tenants/avatar-file().php | 9 +- pages/admin/tenants/create().php | 15 +- pages/admin/tenants/create(default).phtml | 6 +- .../tenants/custom-field-create($id).php | 11 +- .../tenants/custom-field-delete($id).php | 11 +- .../tenants/custom-field-update($id).php | 11 +- pages/admin/tenants/data().php | 32 +- pages/admin/tenants/delete($id).php | 27 +- pages/admin/tenants/edit($id).php | 65 +- pages/admin/tenants/favicon($id).php | 17 +- pages/admin/tenants/favicon-delete($id).php | 17 +- pages/admin/tenants/index().php | 14 +- pages/admin/tenants/index(default).phtml | 2 +- pages/admin/users/access-pdf($id).php | 12 +- pages/admin/users/access-pdf-bulk().php | 12 +- pages/admin/users/activate($id).php | 15 +- pages/admin/users/api-token-create($id).php | 14 +- pages/admin/users/api-token-revoke($id).php | 16 +- pages/admin/users/avatar($id).php | 9 +- pages/admin/users/avatar-delete($id).php | 9 +- pages/admin/users/avatar-file().php | 9 +- pages/admin/users/bulk($action).php | 19 +- pages/admin/users/create().php | 18 +- pages/admin/users/data().php | 25 +- pages/admin/users/deactivate($id).php | 15 +- pages/admin/users/delete($id).php | 17 +- pages/admin/users/edit($id).php | 171 +++-- pages/admin/users/edit(default).phtml | 22 +- pages/admin/users/export().php | 17 + pages/admin/users/forget-tokens($id).php | 22 +- pages/admin/users/index().php | 19 +- pages/admin/users/send-access($id).php | 22 +- pages/api/v1/auth/login().php | 2 +- pages/api/v1/departments/index().php | 14 + pages/api/v1/departments/show($id).php | 33 +- pages/api/v1/me/avatar().php | 63 ++ pages/api/v1/me/index().php | 97 ++- pages/api/v1/me/password().php | 58 ++ pages/api/v1/permissions/index().php | 46 ++ pages/api/v1/roles/show($id).php | 10 + pages/api/v1/settings/index().php | 104 +++ pages/api/v1/settings/public().php | 21 + pages/api/v1/tenants/avatar($id).php | 58 ++ pages/api/v1/tenants/show($id).php | 19 + pages/api/v1/users/avatar($id).php | 68 ++ pages/api/v1/users/index().php | 185 +++++- pages/api/v1/users/show($id).php | 222 +++++-- templates/partials/app-main-aside.phtml | 2 +- .../AuthzAndApiLoginContractTest.php | 471 ++++++++++++++ .../Access/AccessServicesFactoryTest.php | 28 + .../Access/AuthorizationServiceTest.php | 56 ++ .../DepartmentAuthorizationPolicyTest.php | 253 ++++++++ .../PermissionAuthorizationPolicyTest.php | 137 ++++ .../Access/RoleAuthorizationPolicyTest.php | 149 +++++ .../SettingsAuthorizationPolicyTest.php | 118 ++++ .../Access/TenantAuthorizationPolicyTest.php | 310 +++++++++ .../Access/UserAuthorizationPolicyTest.php | 576 +++++++++++++++++ .../Service/Tenant/TenantScopeServiceTest.php | 142 +++++ 119 files changed, 6371 insertions(+), 745 deletions(-) create mode 100644 CLAUDE.md create mode 100644 db/updates/2026-02-23-tenant-scope-global.sql create mode 100644 docs/lib-standards.md create mode 100644 lib/Service/Access/AuthorizationDecision.php create mode 100644 lib/Service/Access/AuthorizationPolicyInterface.php create mode 100644 lib/Service/Access/AuthorizationService.php create mode 100644 lib/Service/Access/DepartmentAuthorizationPolicy.php create mode 100644 lib/Service/Access/PermissionAuthorizationPolicy.php create mode 100644 lib/Service/Access/RoleAuthorizationPolicy.php create mode 100644 lib/Service/Access/SettingsAuthorizationPolicy.php create mode 100644 lib/Service/Access/TenantAuthorizationPolicy.php create mode 100644 lib/Service/Access/UserAuthorizationPolicy.php create mode 100644 pages/api/v1/me/avatar().php create mode 100644 pages/api/v1/me/password().php create mode 100644 pages/api/v1/permissions/index().php create mode 100644 pages/api/v1/settings/index().php create mode 100644 pages/api/v1/settings/public().php create mode 100644 pages/api/v1/tenants/avatar($id).php create mode 100644 pages/api/v1/users/avatar($id).php create mode 100644 tests/Architecture/AuthzAndApiLoginContractTest.php create mode 100644 tests/Service/Access/AuthorizationServiceTest.php create mode 100644 tests/Service/Access/DepartmentAuthorizationPolicyTest.php create mode 100644 tests/Service/Access/PermissionAuthorizationPolicyTest.php create mode 100644 tests/Service/Access/RoleAuthorizationPolicyTest.php create mode 100644 tests/Service/Access/SettingsAuthorizationPolicyTest.php create mode 100644 tests/Service/Access/TenantAuthorizationPolicyTest.php create mode 100644 tests/Service/Access/UserAuthorizationPolicyTest.php create mode 100644 tests/Service/Tenant/TenantScopeServiceTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..29604b6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md — IMVS (ImmobilienMaklerVerkaufssoftware) + +Multi-tenant admin application built on MintyPHP. Manages tenants, users, departments, roles, permissions, address books, mail, CSV imports, scheduled jobs, and Microsoft Entra ID SSO. + +## Tech Stack + +- **Runtime:** PHP 8.5 (FPM) on MintyPHP framework (`mintyphp/core`) +- **Web server:** Nginx 1.25 (Alpine) +- **Database:** MariaDB 11.4 +- **Cache:** Memcached 1.6 +- **Frontend:** Vanilla JS (ES2022 modules), vanilla CSS with `@layer` system — no build step +- **Key PHP libs:** PHPMailer 7, Dompdf 3, endroid/qr-code 6, league/commonmark 2 +- **Containerized:** Docker Compose (dev + prod variants) + +## Quick Commands + +```bash +# Start dev environment +docker compose up --build -d +# App: http://localhost:8080 | phpMyAdmin: http://localhost:8081 + +# Run PHPUnit tests +docker compose exec php vendor/bin/phpunit --bootstrap vendor/autoload.php + +# Run PHPStan (level 5) +docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress + +# Run ESLint +npx eslint web/js + +# Check unused Composer packages +docker compose exec php vendor/bin/composer-unused +``` + +## Project Structure + +``` +lib/ # All backend PHP (namespace MintyPHP\) + Repository/ # SQL queries, prepared statements, filters/paging + Service/ # Business logic, validation, orchestration + Http/ # Auth guards, API auth, request helpers + Support/ # Crypto, flash, guard, search, helpers +pages/ # Actions (controllers) — file-based routing + pages/**/*.php # Action files (read input, check permissions, call services) + pages/**/*.phtml # View files (rendering only, no DB calls) +templates/ # Layouts and partials + partials/ # Reusable UI components + emails/ # Email HTML templates + pdfs/ # PDF templates (Dompdf) +config/ # App config (routes, assets, settings cache, themes) +web/ # Document root (entry point, CSS, JS, static assets) + js/core/ # DOM utilities, Grid.js factory + js/components/ # Reusable UI components + css/ # Layered CSS (base → components → layout → pages) +db/init/init.sql # Full schema + seed data (no migration framework) +tests/ # PHPUnit tests (namespace MintyPHP\Tests\) +docs/ # German-language documentation (Markdown) +i18n/ # Translation files (de, en) +bin/ # CLI scripts (scheduler) +docker/ # Dockerfiles, Nginx configs, PHP configs +``` + +## Architecture Rules + +### Strict Layering (enforce in all changes) + +1. **Repository** (`lib/Repository/`): All SQL lives here. No HTTP, session, or rendering. +2. **Service** (`lib/Service/`): Business rules and validation. No HTML. Uses factory pattern (`*ServicesFactory`). +3. **Action** (`pages/**/*.php`): Reads input, checks permissions + tenant scope, calls services, sets view vars. +4. **View** (`pages/**/*.phtml`): Pure rendering. **No DB calls.** HTML escaping via `e(...)`. +5. **Template** (`templates/`): Layouts and partials. + +### Routing + +- File-based: `pages/admin/users/edit($id).php` → URL parameter `id` +- `...(none).phtml` → no template layout +- `data().php` suffix → data/AJAX endpoints +- Named aliases in `config/routes.php` with `public` flag for auth guard + +### PHP Conventions + +- PSR-4 autoload: `MintyPHP\` → `lib/`, `MintyPHP\Tests\` → `tests/` +- All user-facing text uses `t('key')` translation helper +- Permissions + tenant scope enforced in actions/services, never just in UI +- Security/integration settings stay in DB, not in `config/settings.php` file cache + +### Frontend Conventions + +- Vanilla ES2022 modules, no bundler +- CSS classes prefixed with `app-` +- Defensive DOM checks (elements may not exist on every page) +- No business logic in frontend +- Assets served raw with `assetVersion()` cache-busting (filemtime-based) + +### UI Patterns + +- Edit pages: tabs → `Master data` | `Visibility` | optional `Danger zone` +- Titlebar partial for primary actions (Save, Save & close) +- Secondary actions in Aside block via `app-details-aside-actions.phtml` +- Lists use Grid.js +- All visible text wrapped in `t('...')` + +## Database + +- Schema defined in `db/init/init.sql` (applied once on container init) +- No migration framework — schema changes for existing installs are idempotent SQL scripts +- Settings stored in `settings` DB table (single source of truth); `config/settings.php` is a partial file cache for hot-path UI reads only + +## Quality Gates + +- **PHPStan level 5** — scans `config/`, `lib/`, `pages/`, `tests/` +- **PHPUnit 11** — bootstrap: `tests/bootstrap.php` +- **ESLint** — ES2022, strict `eqeqeq`, `curly`, `no-unused-vars`, `no-console` (warn, allow warn/error) +- **composer-unused** — periodic check for unused dependencies + +## Environment + +- Config via `.env` (gitignored) — copy from `.env.example` for dev, `.env.prod.example` for prod +- `config/config.php` (gitignored) — copy from `config/config.php.example` +- `APP_CRYPTO_KEY` (64-char hex) needed for AES-256-GCM encryption of SSO secrets diff --git a/db/init/init.sql b/db/init/init.sql index d47f073..c50c719 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -724,6 +724,7 @@ VALUES ('permissions.delete', 'Can delete permissions', 1, 1), ('settings.view', 'Can view settings', 1, 1), ('settings.update', 'Can update settings', 1, 1), + ('tenant.scope.global', 'Can bypass tenant scope globally', 1, 1), ('imports.view', 'Can view imports', 1, 1), ('imports.audit.view', 'Can view import audit logs', 1, 1), ('jobs.view', 'Can view scheduled jobs', 1, 1), @@ -801,7 +802,7 @@ JOIN permissions p ON p.`key` IN ( 'departments.view', 'departments.create', 'departments.update', 'departments.delete', 'departments.import', 'roles.view', 'roles.create', 'roles.update', 'roles.delete', 'permissions.view', 'permissions.create', 'permissions.update', 'permissions.delete', - 'settings.view', 'settings.update', + 'settings.view', 'settings.update', 'tenant.scope.global', 'imports.view', 'imports.audit.view', 'jobs.view', 'jobs.manage', 'jobs.run_now', 'user_lifecycle_audit.view', 'users.import', 'users.import_assignments', 'users.lifecycle_restore', diff --git a/db/updates/2026-02-23-tenant-scope-global.sql b/db/updates/2026-02-23-tenant-scope-global.sql new file mode 100644 index 0000000..e286ef3 --- /dev/null +++ b/db/updates/2026-02-23-tenant-scope-global.sql @@ -0,0 +1,15 @@ +-- Idempotent update: introduce explicit global tenant-scope bypass permission. + +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) +VALUES ('tenant.scope.global', 'Can bypass tenant scope globally', 1, 1) +ON DUPLICATE KEY UPDATE + `description` = VALUES(`description`), + `active` = VALUES(`active`), + `is_system` = VALUES(`is_system`); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'tenant.scope.global' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/docs/anfragelimits.md b/docs/anfragelimits.md index 6cb0651..d21261f 100644 --- a/docs/anfragelimits.md +++ b/docs/anfragelimits.md @@ -1,6 +1,6 @@ # Anfragelimits (Rate Limiting) -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-23 Dieses Dokument beschreibt das aktuelle zentrale Rate-Limiting für Login und API. @@ -64,6 +64,7 @@ Bei überschreitung: Integration: - `/pages/auth/login().php` +- `/pages/api/v1/auth/login().php` (public Login ohne Bearer) Aktuelle Limits: @@ -84,6 +85,27 @@ Verhalten: - UI zeigt generische Meldung: `Too many login attempts. Please wait and try again.` - Bei erfolgreichem Passwort-Login wird der Passwort-Scope für diesen Key zurückgesetzt. +### API-Login (`/api/v1/auth/login`) + +Zusätzlich zum allgemeinen API-Limit in `ApiBootstrap` gelten eigene Login-Limits: + +1. Schritt `login_ip` (IP) +- Scope: `api.auth.login.ip` +- Key: `` +- Limit: `10` Fehlversuche pro `600` Sekunden +- Block: `300` Sekunden + +2. Schritt `login_email_ip` (email + ip) +- Scope: `api.auth.login.email_ip` +- Key: `|` +- Limit: `5` Fehlversuche pro `900` Sekunden +- Block: `900` Sekunden + +Verhalten: +- Endpoint bleibt public (kein Bearer erforderlich). +- Bei Block: HTTP `429` + `Retry-After`. +- Bei erfolgreichem API-Login wird `api.auth.login.email_ip` für den Key zurückgesetzt. + ## Datenfluss pro Request 1. Key bilden (`scope` + Subject) diff --git a/docs/architektur.md b/docs/architektur.md index 041266f..c6c427b 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -1,201 +1,58 @@ -# Architektur +# Architektur-Kompass -Letzte Aktualisierung: 2026-02-22 (geprüft) +Letzte Aktualisierung: 2026-02-23 -## 1) Zielbild +Kurze, verbindliche Architekturübersicht für den aktuellen Stand. -Die Anwendung ist eine servergerenderte Multi-Tenant-Admin-Anwendung mit klarer Trennung von: +## Begriffe (kurz) -- HTTP/Request-Flow (`/web/index.php`, `lib/Http/*`) -- Action-Layer (`/pages/**`) -- Business-Logik (`/lib/Service/**`) -- Datenzugriff (`/lib/Repository/**`) -- Rendering (`/templates/**`, `*.phtml`) -- Frontend-Verhalten (`/web/js/**`) +- `Tenant`: Mandant, isolierter Daten- und Berechtigungsraum. +- `Department`: optionale organisatorische Untereinheit innerhalb eines Tenants. +- `Role`: Bündel von Berechtigungen. +- `Permission`: einzelne Berechtigung wie `users.view`. +- `Policy`: zentrale Autorisierungsregel für eine konkrete Fähigkeit. +- `Ability`: technischer Name einer Policy-Regel, z. B. `admin.users.edit.submit`. +- `AuthorizationDecision`: Ergebnis einer Policy (`allow/deny`, HTTP-Status, Fehlercode, Attribute). +- `Tenant-Scope`: Regel, ob ein User eine Ressource im Ziel-Tenant sehen oder ändern darf. +- `Request`: einzelner HTTP-Aufruf. +- `Action`: Datei unter `pages/**`, verarbeitet den Request. +- `Service`: Fachlogik unter `lib/Service/**`. +- `Repository`: SQL- und Mapping-Schicht unter `lib/Repository/**`. +- `UUID-first`: externe API gibt nur UUIDs aus, keine internen IDs. +- `Contract-Test`: Test für Architektur- und Schnittstellenregeln. -## 2) Request-Lebenszyklus +## Zielbild (Stand heute) -Zentraler Entry Point ist `/web/index.php`. +- Server-gerenderte App plus REST-API unter `/api/v1`. +- Strikte Multi-Tenant-Isolation. +- Zentrale Autorisierung über `AuthorizationService` und Policies. +- API-Verträge sind UUID-first. -Ablauf pro Request: +## Request-Flow (verbindlich) -1. Bootstrap (`vendor/autoload.php`, `config/config.php`, `config/router.php`, Helper) -2. Firewall + Session Start -3. Locale-Ermittlung (URL/Session/Cookie/default) -4. Remember-Me Auto-Login -5. Optionales Tenant-Snapshot-Refresh in Session -6. Redirect auf locale-prefixed URL (falls nötig) -7. Access Guard (`AccessControl`) für Public/Protected Paths -8. Action-Layer aus `pages/**` laden -9. View + Template rendern -10. Session beenden, DB schließen +1. `web/index.php` lädt Bootstrap, Routing und Konfiguration. +2. Firewall, Session, Locale und Access-Control laufen. +3. Action unter `pages/**` nimmt Request an. +4. Action delegiert Fachlogik an Services. +5. Services nutzen Repositories für Datenzugriff. +6. Response wird als HTML, JSON oder Datei ausgegeben. -## 3) Routing-Modell +## Schichtregeln (MUSS) -- Route-Definitionen (Pfad-Aliase) liegen in `/config/routes.php`. -- Registrierung erfolgt in `/config/router.php` (liest nur `path` und `target`; das `public`-Feld wird vom Router nicht ausgewertet). -- Welche Pfade ohne Login erreichbar sind, steuert `lib/Http/AccessControl.php`: - - Hartkodierte `ALWAYS_PUBLIC_PREFIXES` (z. B. `auth/microsoft/`, `api/`, `branding/`, `flash/`) - - `APP_PUBLIC_PATHS`-Konstante (gesetzt in `config/config.php`) +- `pages/**`: Input, Auth/AuthZ, Service-Aufruf, Response. Keine duplizierte Business-Logik. +- `lib/Service/**`: Fachlogik und Orchestrierung. Keine Superglobals, keine Header/Redirect-Ausgabe. +- `lib/Repository/**`: SQL, Filter, Paging, Mapping. Keine HTTP-, Session- oder Policy-Logik. +- `templates/**`: nur Rendering. -MintyPHP-Konvention (Dateinamensrouting): +## Sicherheits- und API-Regeln (MUSS) -- `pages/admin/users/edit($id).php` erwartet URL-Parameter `id` -- `pages/admin/users/data().php` für Datenendpunkte -- `...(none).phtml` für responses ohne Template-Layout -- Binärausgaben (z. B. Onboarding-PDF/ZIP) setzen `MINTY_ALLOW_OUTPUT` im Action-Endpoint. +- Autorisierung nur zentral über Policies. +- Tenant-Scope nicht lokal nachbauen. +- Scope-Denials pro Endpunkt-Semantik als `403` oder `404`. +- API-Fehler einheitlich: `error`, optional `errors`. +- OpenAPI wird bei API-Änderungen im selben Merge aktualisiert. -## 4) Schichten und Verantwortungen +## Merge-Mindestchecks -### Action-Layer (`/pages`) - -- Eingaben lesen/validieren -- Permissions und Tenant-Scope prüfen -- Services aufrufen -- View-Daten vorbereiten - -### Service-Layer (`/lib/Service`) - -- Geschäftsregeln -- Cross-Cutting-Logik (z. B. Auth, Settings, Branding) -- Orchestrierung mehrerer Repositories -- Scheduler/Lifecycle sind instanzbasiert über `lib/Service/Scheduler/SchedulerServicesFactory.php` verdrahtet. -- Import/Import-Audit sind instanzbasiert über `lib/Service/Import/ImportServicesFactory.php` verdrahtet. -- User-Domain ist instanzbasiert über `lib/Service/User/UserServicesFactory.php` verdrahtet - (Services: `UserAccountService`, `UserAssignmentService`, `UserTenantContextService`, `UserPasswordService`; - Repositories: `UserReadRepository`, `UserWriteRepository`, `UserListQueryRepository` + Assignment-Repositories). -- Auth-/SSO-Domain ist instanzbasiert über `lib/Service/Auth/AuthServicesFactory.php` verdrahtet - (u. a. `AuthService`, `RememberMeService`, `SsoUserLinkService`, `TenantSsoService`, `MicrosoftOidcService`). - - Auth-Infra-Repositories (`ApiTokenRepository`, `RememberTokenRepository`, `PasswordResetRepository`, - `EmailVerificationRepository`) werden ebenfalls instanzbasiert über die Factory injiziert. - -### Repository-Layer (`/lib/Repository`) - -- SQL und Mapping -- Filter-/Paging-Mechaniken -- Keine UI-/HTTP-Logik - -### Template/View-Layer (`/templates`, `*.phtml`) - -- Nur Rendering -- Keine DB-Zugriffe -- Wiederkehrende UI-Teile als Partials - -## 5) Datenmodell (fachlich) - -Kernobjekte: - -- `users` -- `tenants` -- `departments` -- `roles` -- `permissions` - -Verknüpfungen: - -- `user_tenants`, `user_departments`, `user_roles` -- `role_permissions` -- `tenant_auth_microsoft`, `user_external_identities` -- `tenant_custom_field_definitions`, `tenant_custom_field_options` -- `user_custom_field_values`, `user_custom_field_value_options` - -Hinweis: - -- `departments` ist 1:1 an `tenants` gebunden über `departments.tenant_id` (kein M:N-Mapping). -- In der User-Organisation wird die Department-Auswahl tenantweise gerendert (ein Multi-Select je zugewiesenem Tenant), gespeichert wird weiterhin flach über `user_departments`. -- Tenant-spezifische User-Zusatzfelder werden separat modelliert: - - Definitionen/Optionen pro Tenant in `tenant_custom_field_*` - - Werte pro User in `user_custom_field_*` - - Filterung im Address Book über dynamische Query-Keys `cf_*`, `cfm_*`, `cfd_*`. -- `field_key` bleibt intern (tenant-weit eindeutig), wird im Tenant-UI automatisch aus dem Label erzeugt und nicht manuell gepflegt. -- `is_filterable` ist fachlich nur für `select`, `multiselect`, `boolean`, `date` aktiv. -- User-Zusatzfeldwerte sind in V1 optional (keine Pflichtvalidierung). -- Tenant-Theme-Overrides werden direkt auf `tenants` gespeichert: - - `tenants.default_theme` (`NULL` = globales `app_theme`) - - `tenants.allow_user_theme` (`NULL` = globales `app_theme_user`). - - Auflösung immer über `$_SESSION['current_tenant']` und damit tenant-spezifisch für Multi-Tenant-User. -- Tenant-SSO für Microsoft Entra ID: - - Konfiguration pro Tenant in `tenant_auth_microsoft`. - - Externe Identitäts-Verknüpfung stabil über `user_external_identities` (`provider + tid + oid`). - - Login-Endpunkte: `login` (optional `?tenant={slug}`), `auth/microsoft/start`, `auth/microsoft/callback`. - - Shared-App-Credentials liegen global in `settings`; optionale Tenant-Overrides sind möglich. - - Profil-Sync wird tenant-spezifisch über `tenant_auth_microsoft.sync_profile_on_login` und - `tenant_auth_microsoft.sync_profile_fields` gesteuert. - - Erlaubte Sync-Felder in V1: `first_name`, `last_name`, `phone`, `mobile`, `avatar`. - - `phone/mobile/avatar` kommen über Microsoft Graph (`/me`, `/me/photo/$value`) und sind fail-open - (Graph-Fehler blockieren den Login nicht). - -Sicherheits-/Audit-nahe Tabellen: - -- `user_remember_tokens` -- `password_resets` -- `email_verifications` -- `mail_log` - -## 6) Sicherheits- und Scope-Modell - -- Auth Guard: protected by default -- Permission Checks: feature-spezifisch in Actions/Services -- Tenant Scope: datenabhängig über Tenant-Matches -- Strictness steuerbar über `TENANT_SCOPE_STRICT` - -Details siehe: -`/docs/sicherheitsmodell.md` - -## 7) Frontend-Architektur - -JS-Struktur: - -- `/web/js/core` Basismodule (DOM/Grid Factory) -- `/web/js/components` UI-Komponenten -- `/web/js/pages` seitenspezifische Helfer - -Bootstrap: - -- `app-boot.js` früh (no-js/js state, localStorage UI state) -- `app-init.js` für Default-Template -- `app-login-init.js` für Login-Template - -CSS-Struktur: - -- Layer-Entrypoint: `/web/css/app-layers.css` -- Basen/Variablen in `/web/css/base` -- Layout in `/web/css/layout` -- Komponenten in `/web/css/components` -- Seitenstile in `/web/css/pages` -- Vendor-Overrides in `/web/css/vendor-overrides` - -## 8) Asset-Steuerung - -Konfiguration liegt in `/config/assets.php`. - -- Template-Gruppen: `default`, `login` -- Seiten-Gruppen optional via `Buffer::set('style_groups', ...)` -- Rendering über Helper: `renderTemplateStyles()` und `renderPageStyles()` - -## 9) Erweiterungsstrategie - -Pragmatischer Weg für neue Features: - -1. Repository erweitern (SQL) -2. Service-Regel bauen -3. Action anbinden -4. View/Partial integrieren -5. JS nur falls interaktiv nötig -6. Tests + Lint/Analyse laufen lassen - -So bleibt die Trennung stabil und Refactoring kontrollierbar. - -## 10) Settings-Architektur (DB + Datei-Cache) - -- `settings` (DB) ist die alleinige fachliche Quelle für globale Settings. -- `config/settings.php` ist ein technischer Teil-Cache für sehr häufig gelesene UI-Basiswerte. -- Zugriffsmuster: - - `SettingGateway` (verdrahtet über `SettingServicesFactory`) -> `SettingService` -> DB - - `appSetting(...)` -> `SettingServicesFactory` -> `SettingCacheService` (Datei-Cache) -- Konsequenz: - - Es ist korrekt, dass nicht alle DB-Settings im Datei-Cache stehen (z. B. SMTP/SSO/API-Details). - - Der Cache muss nur die Keys enthalten, die im Runtime-Helper `appSetting(...)` verwendet werden. - -Details: -`/docs/einstellungen-speicherung.md` +- `docker compose exec php vendor/bin/phpunit` +- `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` diff --git a/docs/entwickler-checkliste.md b/docs/entwickler-checkliste.md index fe934fd..55b4423 100644 --- a/docs/entwickler-checkliste.md +++ b/docs/entwickler-checkliste.md @@ -1,12 +1,13 @@ # Entwickler-Checkliste -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-23 ## Vor dem Coden - [ ] Betroffene Schicht klar? (`Repository`, `Service`, `pages`, `templates`, `web/js`) - [ ] Permission- und Tenant-Scope-Auswirkungen verstanden? - [ ] Bestehendes Partial/Helper wiederverwendbar? +- [ ] Bei `lib/**`-Änderungen Regeln in `/docs/lib-standards.md` geprüft ## Beim Implementieren diff --git a/docs/erste-aenderung.md b/docs/erste-aenderung.md index 65d28d1..261c81c 100644 --- a/docs/erste-aenderung.md +++ b/docs/erste-aenderung.md @@ -35,7 +35,7 @@ Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umse 3. Optional Service/Repository ergänzen 4. `docs/openapi.yaml` aktualisieren 5. `docs/api.md` mit Beispiel aktualisieren -6. Bei neuen Berechtigungen: `PermissionService` + `init.sql` synchron halten, für Bestandsumgebungen idempotentes SQL-Update bereitstellen +6. Bei neuen Berechtigungen: `PermissionService` + `init.sql` synchron halten, für Bestandsumgebungen idempotentes SQL-Update in `db/updates/*.sql` bereitstellen ### Done-Kriterien diff --git a/docs/fehlerbehebung.md b/docs/fehlerbehebung.md index 716cfd1..9d0ebcf 100644 --- a/docs/fehlerbehebung.md +++ b/docs/fehlerbehebung.md @@ -1,6 +1,6 @@ # Fehlerbehebung -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-23 ## App reagiert nicht wie erwartet @@ -22,10 +22,11 @@ docker compose restart php nginx ### Checks -1. Authorization Header korrekt gesetzt (`Bearer `) -2. Token nicht revoked/abgelaufen -3. Token hat erforderliche Permission -4. Bei tenant-scoped Token: Zugriff nur auf passende Tenant-Ressourcen +1. Für geschützte Endpunkte Authorization Header korrekt gesetzt (`Bearer `) +2. Ausnahme: `/api/v1/auth/login` ist public und benötigt keinen Bearer-Header +3. Token nicht revoked/abgelaufen +4. Token hat erforderliche Permission +5. Bei tenant-scoped Token: Zugriff nur auf passende Tenant-Ressourcen ## Migration oder Schema-Probleme diff --git a/docs/index.md b/docs/index.md index 7c2a686..e9db475 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Dokumentation -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-23 Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erweitern. @@ -20,11 +20,13 @@ Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erwe ## Architektur und Regeln - `/docs/architektur.md` - - Request-Lebenszyklus, Schichtentrennung, Frontend-Aufbau. + - Startpunkt mit Glossar, Zielbild, Schichtregeln und Sicherheitsleitplanken. - `/docs/sicherheitsmodell.md` - Auth, Permissions, Tenant-Scope, Public/API-Zugriffe. - `/docs/konventionen.md` - - Coding- und Strukturkonventionen für PHP, Templates, CSS, JS. + - Kurze, verbindliche Arbeitsregeln für Code, API und Qualität. +- `/docs/lib-standards.md` + - Konkrete Do/Don't-Regeln für `lib/**`. ## Frontend diff --git a/docs/konventionen.md b/docs/konventionen.md index daf918f..bcf04dc 100644 --- a/docs/konventionen.md +++ b/docs/konventionen.md @@ -1,129 +1,41 @@ -# Konventionen +# Konventionen (kurz und verbindlich) -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-23 -Diese Regeln sind projektweit verbindlich, um Lesbarkeit und Wartbarkeit stabil zu halten. +## 1) Code -## 1) Allgemein +- Kleine, fokussierte Änderungen statt Misch-Commits. +- Kein toter Code, keine ungenutzten Helpers. +- ASCII als Standard, Unicode nur wenn Datei es bereits nutzt. -- ASCII als Standard, außer bestehende Datei nutzt bereits Unicode. -- Keine toten Pfade/Codezweige einbauen. -- Wiederholung lieber zentralisieren (Partial/Helper/Utility) als kopieren. -- Kleine, fokussierte Commits/Changesets bevorzugen. +## 2) Schichten -## 2) PHP-Schichten +- SQL nur in `lib/Repository/**`. +- Fachlogik nur in `lib/Service/**`. +- `pages/**` bleibt dünn: Input, Auth/AuthZ, Service-Aufruf, Response. +- Neue Domain-Logik als Instanz-Service mit Factory-Verdrahtung. -### Repository +## 3) Autorisierung -- Enthalten SQL und Filterlogik. -- Keine HTTP-/Session-/Render-Logik. -- Prepared Statements und bestehende Query-Utilities nutzen. +- Nur zentrale Policy-Abilities nutzen. +- Keine verteilten `userHas(...)`-Checks als Hauptlogik. +- Tenant-Scope nicht in Templates oder JavaScript nachbauen. -### Service +## 4) API -- Geschäftsregeln, Validierung, Orchestrierung. -- Keine HTML-Ausgabe. -- Keine direkten View-Annahmen. -- Bei globalen Settings bleibt DB die Quelle (`SettingService`); Datei-Cache nur gezielt für `appSetting(...)`. -- Für neue oder refaktorierte Domain-Module Instanz-Services + Factory verwenden (z. B. `...ServicesFactory`) statt statischer Domain-Methoden. -- Keine domain-spezifischen Service-Locator-Helper in `lib/Support/helpers/app.php`; Composition per Factory im Aufrufer. +- UUID-first für externe Ressourcen. +- Interne IDs nicht nach außen geben. +- Fehlerantworten einheitlich halten (`error`, optional `errors`). +- OpenAPI bei jeder API-Änderung im selben Merge aktualisieren. -### Action (`pages/**.php`) +## 5) Frontend -- Request lesen, Zugriff prüfen, Service aufrufen, Variablen für View setzen. -- Redirects/Flash-Messages hier zentral steuern. +- Templates nur für Rendering. +- UI-Texte über `t('...')`. +- Strukturregeln in `docs/frontend-css.md` und `docs/frontend-javascript.md`. -### Views (`*.phtml`) +## 6) Mindestchecks vor Merge -- Reines Rendering. -- Keine DB-Calls. -- HTML escapen (`e(...)`) außer bewusst gewünscht. - -## 3) UI-Konventionen - -### Edit-Seiten - -- Einheitliche Titelzeile mit `/templates/partials/app-details-titlebar.phtml`. -- In der Titelzeile nur Primäraktionen halten (typisch: `Save`, `Save & close`). -- Sekundäraktionen nicht im Drei-Punkte-Menü verstecken, sondern im Aside als einklappbarer Block `Actions` über `/templates/partials/app-details-aside-actions.phtml`. -- Partial-Naming klar trennen: - - linkes Haupt-Aside: `app-main-aside*` - - rechtes Details-Aside: `app-details-aside*` -- Tab-Reihenfolge: - - erster Tab `Master data` - - später `Visibility` - - optional letzter Tab `Danger zone` -- Statusfeld über `/templates/partials/app-visibility-status-field.phtml`. -- Delete-Aktion über `/templates/partials/app-danger-zone-delete-field.phtml`. -- Aside-Metadaten über: - - `/templates/partials/app-details-aside-audit.phtml` - - `/templates/partials/app-details-aside-ids.phtml` -- Tenant-Zusatzfelder: - - Definitionen werden im Tenant-Tab `Custom fields` über eigene POST-Actions gepflegt (nicht über Tenant-Hauptsave). - - `field_key` wird nicht im UI gepflegt; serverseitig aus Label erzeugt und tenant-weit eindeutig gehalten. - - `is_filterable` nur für `select`, `multiselect`, `boolean`, `date` anbieten. - - User-Werte werden im User-Tab `Custom fields` tenantweise gruppiert gerendert. - - User-Zusatzfelder sind optional; keine Pflichtvalidierung in V1. - -### Listen - -- Grid.js als Standard. -- Filter/Toolbar konsistent halten. -- Aktionsspalten nur wenn wirklich nötig; sonst Double-Click/Row-Action nutzen. -- Dynamische Address-Book-Filter für Zusatzfelder folgen festen Parametern: - - `cf_` (scalar/select/bool) - - `cfm_` (multiselect als CSV) - - `cfd__from|to` (date range) - -## 4) CSS - -- Prefix `app-` für eigene Klassen/Dateien. -- Vendor-Anpassungen in `/web/css/vendor-overrides`. -- Layering über `/web/css/app-layers.css`. -- Theme-/Contrast-Werte über zentrale Variablen in `/web/css/base`. - -## 5) JavaScript - -- Struktur: - - `core/` für Basismodule - - `components/` für wiederverwendbare UI-Module - - `pages/` für seitenspezifische Logik -- Initialisierung zentral in `app-init.js` bzw. `app-login-init.js`. -- Defensive DOM-Checks (Element kann auf manchen Seiten fehlen). -- Keine Business-Logik im Frontend verstecken. - -## 6) i18n - -- Alle sichtbaren UI-Texte über `t('...')`. -- Übersetzungs-Keys in `i18n/default_de.json` und `i18n/default_en.json` pflegen. -- Test nutzen: `tests/I18n/TranslationKeysTest.php`. - -## 7) Qualitätssicherung - -Vor Merge mindestens: - -- PHPUnit -- PHPStan -- ESLint (web/js) -- Betroffene Kern-Userflows manuell testen - -Checkliste siehe: -`/docs/entwickler-checkliste.md` - -### Dependency-Hygiene (periodisch, nicht bei jedem Commit) - -Ungenutzte Composer-Pakete prüfen: - -```bash -docker compose exec php vendor/bin/composer-unused -``` - -Ausführen bei: Feature-Entfernung, größerem Refactoring oder vor Releases. Pakete die fälschlicherweise als unused gemeldet werden (z.B. dynamisch geladen), können in `composer.json` unter `extra.unused` ignoriert werden. - -## 8) Settings-Konvention - -- Neue Settings immer zuerst sauber im `SettingService` modellieren (Validierung + Read/Write). -- Aufrufer nutzen `SettingGateway` aus `SettingServicesFactory`; kein globaler `settingGateway()`-Helper. -- Nur dann in den `SettingCacheService` aufnehmen, wenn der Key wirklich über `appSetting(...)` im Hot Path genutzt wird. -- Keine Sicherheits-/Integrationssettings (SMTP, SSO-Secrets, API-Policies) als Pflicht in den Datei-Cache drücken. -- Bei manuellen DB-Änderungen an gecachten Keys den Cache gezielt aktualisieren (z. B. über Settings-Save-Flow). +- `docker compose exec php vendor/bin/phpunit` +- `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress` +- bei JS-Änderungen: `npx eslint web/js` diff --git a/docs/lib-standards.md b/docs/lib-standards.md new file mode 100644 index 0000000..6cb725b --- /dev/null +++ b/docs/lib-standards.md @@ -0,0 +1,39 @@ +# lib-Standards + +Letzte Aktualisierung: 2026-02-23 + +Praktische Regeln nur für `lib/**`. Ergänzt `docs/architektur.md`. + +## 1) Repository-Regeln + +- Verantwortlich für SQL, Filter, Paging und Mapping. +- Keine HTTP-, Session-, Redirect- oder Policy-Logik. + +## 2) Service-Regeln + +- Verantwortlich für Fachlogik und Orchestrierung. +- Keine Superglobals (`$_POST`, `$_GET`, `$_SESSION`). +- Keine Header- oder Redirect-Ausgabe. +- Abhängigkeiten per Constructor Injection. + +## 3) Instanziierung + +- Domain-Abhängigkeiten mit `new` nur in Factories/Bootstrap. +- Für neue Domain-Logik keine statischen Utility-Klassen. + +## 4) Autorisierung und Scope + +- Zentral über `AuthorizationService` und Policy-Abilities. +- Keine verteilten Einzelchecks als primäre Zugriffslogik. + +## 5) API-Contract + +- Extern UUID-first. +- Interne IDs nur intern auflösen und mappen. + +## 6) Definition of Done für `lib/**` + +1. Schichtgrenzen eingehalten. +2. AuthZ zentral. +3. Unit-Tests ergänzt, bei Strukturänderung zusätzlich Contract-Test. +4. `phpunit` und `phpstan` grün. diff --git a/docs/lokale-entwicklung.md b/docs/lokale-entwicklung.md index 72a4e59..70983ae 100644 --- a/docs/lokale-entwicklung.md +++ b/docs/lokale-entwicklung.md @@ -25,7 +25,7 @@ docker compose restart php nginx - Basis-Schema + Seeds: `db/init/init.sql` - Das Repo nutzt aktuell ein konsolidiertes Init-Schema (keine separaten Migrationsdateien in `db/init/`). -- Für Bestandsumgebungen Schema-/Permission-Änderungen als idempotentes SQL-Update bereitstellen und manuell ausführen. +- Für Bestandsumgebungen Schema-/Permission-Änderungen als idempotentes SQL-Update in `db/updates/*.sql` bereitstellen und manuell ausführen. Beispiel (bestehende Umgebung, SQL-Update manuell ausführen): diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 479ef62..b85a97e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -18,6 +18,8 @@ tags: - name: tenants - name: departments - name: roles + - name: permissions + - name: settings paths: /auth/login: post: @@ -88,6 +90,133 @@ paths: $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' + put: + tags: [me] + summary: Update authenticated user profile + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MeUpdateRequest' + responses: + '200': + description: Updated profile + content: + application/json: + schema: + $ref: '#/components/schemas/MeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [me] + summary: Patch authenticated user profile + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MeUpdateRequest' + responses: + '200': + description: Updated profile + content: + application/json: + schema: + $ref: '#/components/schemas/MeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /me/password: + post: + tags: [me] + summary: Change password for authenticated user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MePasswordRequest' + responses: + '204': + description: No Content + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /me/avatar: + get: + tags: [me] + summary: Get avatar image for authenticated user + parameters: + - $ref: '#/components/parameters/AvatarSize' + responses: + '200': + description: Avatar binary + content: + image/jpeg: {} + image/png: {} + image/webp: {} + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + post: + tags: [me] + summary: Upload avatar for authenticated user + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [avatar] + properties: + avatar: + type: string + format: binary + responses: + '201': + $ref: '#/components/responses/DataObject' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + delete: + tags: [me] + summary: Delete avatar for authenticated user + responses: + '204': + description: No Content + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' /me/tokens: get: @@ -350,6 +479,29 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /users/avatar/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + - $ref: '#/components/parameters/AvatarSize' + get: + tags: [users] + summary: Get avatar image by user UUID + responses: + '200': + description: Avatar binary + content: + image/jpeg: {} + image/png: {} + image/webp: {} + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /tenants: get: tags: [tenants] @@ -474,6 +626,29 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /tenants/avatar/{uuid}: + parameters: + - $ref: '#/components/parameters/UuidPath' + - $ref: '#/components/parameters/AvatarSize' + get: + tags: [tenants] + summary: Get avatar image by tenant UUID + responses: + '200': + description: Avatar binary + content: + image/jpeg: {} + image/png: {} + image/webp: {} + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + /departments: get: tags: [departments] @@ -722,6 +897,112 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /permissions: + get: + tags: [permissions] + summary: List permissions + parameters: + - $ref: '#/components/parameters/Limit25' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/Search' + - $ref: '#/components/parameters/OrderPermissions' + - $ref: '#/components/parameters/Dir' + - $ref: '#/components/parameters/ActiveFilter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + + /settings: + get: + tags: [settings] + summary: Get application settings snapshot + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + put: + tags: [settings] + summary: Update application settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsUpdateRequest' + responses: + '200': + description: Updated settings snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + patch: + tags: [settings] + summary: Patch application settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsUpdateRequest' + responses: + '200': + description: Updated settings snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + + /settings/public: + get: + tags: [settings] + summary: Get public app settings for unauthenticated clients + security: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GenericDataResponse' + '429': + $ref: '#/components/responses/RateLimited' + components: securitySchemes: BearerAuth: @@ -777,6 +1058,13 @@ components: schema: type: string description: "Accepted aliases: 1, 0, true, false, active, inactive" + AvatarSize: + name: size + in: query + schema: + type: integer + minimum: 16 + maximum: 1024 OrderUsers: name: order in: query @@ -805,6 +1093,13 @@ components: type: string enum: [id, uuid, description, code, active, created, modified] default: description + OrderPermissions: + name: order + in: query + schema: + type: string + enum: [key, description, active, created] + default: key responses: Unauthorized: description: Unauthorized @@ -930,6 +1225,195 @@ components: format: date-time nullable: true description: UTC expiry timestamp of the token + MeUpdateRequest: + type: object + additionalProperties: false + properties: + first_name: + type: string + last_name: + type: string + profile_description: + type: string + job_title: + type: string + phone: + type: string + mobile: + type: string + short_dial: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + country: + type: string + region: + type: string + locale: + type: string + theme: + type: string + current_tenant_uuid: + type: string + format: uuid + MePasswordRequest: + type: object + additionalProperties: false + required: [current_password, password, password2] + properties: + current_password: + type: string + password: + type: string + password2: + type: string + PermissionListItem: + type: object + properties: + key: + type: string + description: + type: string + active: + type: boolean + is_system: + type: boolean + PermissionListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PermissionListItem' + total: + type: integer + limit: + type: integer + offset: + type: integer + SettingsObject: + type: object + properties: + app_title: + type: string + app_locale: + type: string + app_theme: + type: string + app_theme_user_allowed: + type: boolean + app_registration_enabled: + type: boolean + app_primary_color: + type: string + api_token_default_ttl_days: + type: integer + nullable: true + api_token_max_ttl_days: + type: integer + nullable: true + api_cors_allowed_origins: + type: string + default_tenant_id: + type: integer + nullable: true + default_role_id: + type: integer + nullable: true + default_department_id: + type: integer + nullable: true + user_inactivity_deactivate_days: + type: integer + nullable: true + user_inactivity_delete_days: + type: integer + nullable: true + microsoft_shared_client_id: + type: string + microsoft_authority: + type: string + smtp_host: + type: string + smtp_port: + type: integer + nullable: true + smtp_user: + type: string + smtp_secure: + type: string + smtp_from: + type: string + smtp_from_name: + type: string + SettingsResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/SettingsObject' + SettingsUpdateRequest: + type: object + additionalProperties: false + properties: + app_title: + type: string + app_locale: + type: string + app_theme: + type: string + app_theme_user_allowed: + type: boolean + app_registration_enabled: + type: boolean + app_primary_color: + type: string + api_token_default_ttl_days: + type: integer + nullable: true + api_token_max_ttl_days: + type: integer + nullable: true + api_cors_allowed_origins: + type: string + default_tenant_id: + type: integer + nullable: true + default_role_id: + type: integer + nullable: true + default_department_id: + type: integer + nullable: true + user_inactivity_deactivate_days: + type: integer + nullable: true + user_inactivity_delete_days: + type: integer + nullable: true + microsoft_shared_client_id: + type: string + microsoft_authority: + type: string + smtp_host: + type: string + smtp_port: + type: integer + nullable: true + smtp_user: + type: string + smtp_password: + type: string + smtp_secure: + type: string + smtp_from: + type: string + smtp_from_name: + type: string RoleWriteRequest: type: object additionalProperties: false @@ -946,14 +1430,14 @@ components: DepartmentWriteRequest: type: object additionalProperties: false - required: [description, tenant_id] + required: [description, tenant_uuid] properties: description: type: string minLength: 1 - tenant_id: - type: integer - minimum: 1 + tenant_uuid: + type: string + format: uuid code: type: string cost_center: @@ -1066,24 +1550,24 @@ components: type: string password2: type: string - tenant_ids: + tenant_uuids: type: array items: - type: integer - minimum: 1 - primary_tenant_id: - type: integer - minimum: 1 - role_ids: + type: string + format: uuid + primary_tenant_uuid: + type: string + format: uuid + role_uuids: type: array items: - type: integer - minimum: 1 - department_ids: + type: string + format: uuid + department_uuids: type: array items: - type: integer - minimum: 1 + type: string + format: uuid UserUpdateRequest: type: object additionalProperties: false @@ -1133,24 +1617,24 @@ components: type: string password2: type: string - tenant_ids: + tenant_uuids: type: array items: - type: integer - minimum: 1 - primary_tenant_id: - type: integer - minimum: 1 - role_ids: + type: string + format: uuid + primary_tenant_uuid: + type: string + format: uuid + role_uuids: type: array items: - type: integer - minimum: 1 - department_ids: + type: string + format: uuid + department_uuids: type: array items: - type: integer - minimum: 1 + type: string + format: uuid TenantSummary: type: object properties: @@ -1436,6 +1920,8 @@ components: type: string email: type: string + profile_description: + type: string job_title: type: string phone: @@ -1452,12 +1938,32 @@ components: type: string country: type: string + region: + type: string + hire_date: + type: string + format: date + locale: + type: string + theme: + type: string + email_verified_at: + type: string + nullable: true + last_login_at: + type: string + nullable: true + last_login_provider: + type: string + nullable: true active: type: boolean created: type: string modified: type: string + current_tenant: + $ref: '#/components/schemas/TenantSummary' primary_tenant: $ref: '#/components/schemas/TenantSummary' permissions: @@ -1520,12 +2026,39 @@ components: type: string country: type: string + region: + type: string + vat_id: + type: string + tax_number: + type: string phone: type: string + fax: + type: string email: type: string + support_email: + type: string + support_phone: + type: string + billing_email: + type: string website: type: string + privacy_url: + type: string + imprint_url: + type: string + primary_color: + type: string + primary_color_use_default: + type: boolean + default_theme: + type: string + allow_user_theme_mode: + type: string + enum: ['', '0', '1'] created: type: string modified: @@ -1574,11 +2107,13 @@ components: type: string code: type: string + cost_center: + type: string active: type: boolean - tenant_id: - type: integer - nullable: true + tenant_uuid: + type: string + format: uuid created: type: string modified: @@ -1625,6 +2160,10 @@ components: type: string active: type: boolean + permission_keys: + type: array + items: + type: string created: type: string modified: diff --git a/docs/sicherheitsmodell.md b/docs/sicherheitsmodell.md index 4deea15..1b8ea66 100644 --- a/docs/sicherheitsmodell.md +++ b/docs/sicherheitsmodell.md @@ -1,6 +1,6 @@ # Sicherheitsmodell -Letzte Aktualisierung: 2026-02-22 +Letzte Aktualisierung: 2026-02-23 ## 1) Grundprinzip @@ -33,8 +33,16 @@ Kritisch: - Public Routes werden in `/config/routes.php` mit `public => true` definiert. - Daraus wird zur Laufzeit `APP_PUBLIC_PATHS` aufgebaut. +- Zusätzlich gibt es technische Always-Public-Präfixe in `lib/Http/AccessControl.php` + (u. a. `api/`, `auth/microsoft/`, `branding/`, `flash/`). - Alles andere ist loginpflichtig. +API-Besonderheit: + +- `/api/v1/auth/login` ist bewusst public (ohne Bearer-Header). +- Laufzeitverhalten: `pages/api/v1/auth/login().php` nutzt `ApiBootstrap::init(false)`. +- Alle anderen geschützten API-Endpunkte bleiben bei `ApiBootstrap::init()` (Auth erforderlich). + ## 4) Permission-System - Berechtigungen sind feingranular (`permissions` + `role_permissions`). @@ -53,6 +61,9 @@ Empfehlung: - Theme-Wechsel für User wird tenant-spezifisch abgesichert: - effektive Regel kommt aus `current_tenant.allow_user_theme` (Fallback global `app_theme_user`) - Endpoint `admin/users/theme` blockt bei deaktivierter Regel mit `403` (`theme_disabled`). +- Globaler Tenant-Scope-Bypass ist explizit und separat: + - nur Permission `tenant.scope.global`. + - `settings.update` oder `tenants.update` alleine geben **keinen** Scope-Bypass. - Tenant-Microsoft-SSO ist separat abgesichert: - `tenants.sso_manage` für Pflege der Tenant-SSO-Konfiguration. - Shared-App-Credentials bleiben unter `settings.update`. diff --git a/i18n/default_de.json b/i18n/default_de.json index bc7cd29..4559b77 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -38,6 +38,7 @@ "Choose a new password for your account.": "Wähle ein neues Passwort für dein Konto.", "Password updated": "Passwort aktualisiert", "Password can not be updated": "Passwort kann nicht aktualisiert werden", + "Profile can not be updated": "Profil kann nicht aktualisiert werden", "Reset request not found": "Zurücksetz-Anfrage nicht gefunden", "Reset request already used": "Zurücksetz-Anfrage wurde bereits verwendet", "Reset request expired": "Zurücksetz-Anfrage ist abgelaufen", diff --git a/i18n/default_en.json b/i18n/default_en.json index 914d4b3..8704023 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -38,6 +38,7 @@ "Choose a new password for your account.": "Choose a new password for your account.", "Password updated": "Password updated", "Password can not be updated": "Password can not be updated", + "Profile can not be updated": "Profile can not be updated", "Reset request not found": "Reset request not found", "Reset request already used": "Reset request already used", "Reset request expired": "Reset request expired", diff --git a/lib/Http/ApiBootstrap.php b/lib/Http/ApiBootstrap.php index 710708d..619ea88 100644 --- a/lib/Http/ApiBootstrap.php +++ b/lib/Http/ApiBootstrap.php @@ -27,9 +27,9 @@ class ApiBootstrap * Initialize the API request context. * * Call this at the top of every API page action. - * Sets MINTY_ALLOW_OUTPUT, CORS headers, authenticates via Bearer token. + * Sets MINTY_ALLOW_OUTPUT, CORS headers and optional Bearer auth. */ - public static function init(): void + public static function init(bool $requireAuth = true): void { if (self::$initialized) { return; @@ -51,9 +51,11 @@ class ApiBootstrap self::enforceRateLimit(); - $authenticated = ApiAuth::authenticate(); - if (!$authenticated) { - ApiResponse::unauthorized(); + if ($requireAuth) { + $authenticated = ApiAuth::authenticate(); + if (!$authenticated) { + ApiResponse::unauthorized(); + } } } diff --git a/lib/Service/Access/AccessServicesFactory.php b/lib/Service/Access/AccessServicesFactory.php index c8e405c..1507564 100644 --- a/lib/Service/Access/AccessServicesFactory.php +++ b/lib/Service/Access/AccessServicesFactory.php @@ -5,6 +5,8 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\PermissionRepository; use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Access\UserRoleRepository; +use MintyPHP\Service\Directory\DirectoryScopeGateway; +use MintyPHP\Service\Directory\DirectoryServicesFactory; class AccessServicesFactory { @@ -13,6 +15,15 @@ class AccessServicesFactory private ?UserRoleRepository $userRoleRepository = null; private ?PermissionService $permissionService = null; private ?PermissionGateway $permissionGateway = null; + private ?DirectoryServicesFactory $directoryServicesFactory = null; + private ?DirectoryScopeGateway $directoryScopeGateway = null; + private ?UserAuthorizationPolicy $userAuthorizationPolicy = null; + private ?RoleAuthorizationPolicy $roleAuthorizationPolicy = null; + private ?PermissionAuthorizationPolicy $permissionAuthorizationPolicy = null; + private ?SettingsAuthorizationPolicy $settingsAuthorizationPolicy = null; + private ?TenantAuthorizationPolicy $tenantAuthorizationPolicy = null; + private ?DepartmentAuthorizationPolicy $departmentAuthorizationPolicy = null; + private ?AuthorizationService $authorizationService = null; public function createPermissionRepository(): PermissionRepository { @@ -42,4 +53,71 @@ class AccessServicesFactory { return $this->permissionGateway ??= new PermissionGateway($this->createPermissionService()); } + + public function createUserAuthorizationPolicy(): UserAuthorizationPolicy + { + return $this->userAuthorizationPolicy ??= new UserAuthorizationPolicy( + $this->createPermissionGateway(), + $this->createDirectoryScopeGateway() + ); + } + + public function createRoleAuthorizationPolicy(): RoleAuthorizationPolicy + { + return $this->roleAuthorizationPolicy ??= new RoleAuthorizationPolicy( + $this->createPermissionGateway() + ); + } + + public function createPermissionAuthorizationPolicy(): PermissionAuthorizationPolicy + { + return $this->permissionAuthorizationPolicy ??= new PermissionAuthorizationPolicy( + $this->createPermissionGateway() + ); + } + + public function createSettingsAuthorizationPolicy(): SettingsAuthorizationPolicy + { + return $this->settingsAuthorizationPolicy ??= new SettingsAuthorizationPolicy( + $this->createPermissionGateway() + ); + } + + public function createTenantAuthorizationPolicy(): TenantAuthorizationPolicy + { + return $this->tenantAuthorizationPolicy ??= new TenantAuthorizationPolicy( + $this->createPermissionGateway(), + $this->createDirectoryScopeGateway() + ); + } + + public function createDepartmentAuthorizationPolicy(): DepartmentAuthorizationPolicy + { + return $this->departmentAuthorizationPolicy ??= new DepartmentAuthorizationPolicy( + $this->createPermissionGateway(), + $this->createDirectoryScopeGateway() + ); + } + + public function createAuthorizationService(): AuthorizationService + { + return $this->authorizationService ??= new AuthorizationService([ + $this->createUserAuthorizationPolicy(), + $this->createRoleAuthorizationPolicy(), + $this->createPermissionAuthorizationPolicy(), + $this->createSettingsAuthorizationPolicy(), + $this->createTenantAuthorizationPolicy(), + $this->createDepartmentAuthorizationPolicy(), + ]); + } + + private function createDirectoryScopeGateway(): DirectoryScopeGateway + { + return $this->directoryScopeGateway ??= $this->directoryServicesFactory()->createDirectoryScopeGateway(); + } + + private function directoryServicesFactory(): DirectoryServicesFactory + { + return $this->directoryServicesFactory ??= new DirectoryServicesFactory(); + } } diff --git a/lib/Service/Access/AuthorizationDecision.php b/lib/Service/Access/AuthorizationDecision.php new file mode 100644 index 0000000..0419eae --- /dev/null +++ b/lib/Service/Access/AuthorizationDecision.php @@ -0,0 +1,50 @@ +allowed; + } + + public function status(): int + { + return $this->status; + } + + public function error(): string + { + return $this->error; + } + + public function attributes(): array + { + return $this->attributes; + } + + public function attribute(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->attributes) ? $this->attributes[$key] : $default; + } +} + diff --git a/lib/Service/Access/AuthorizationPolicyInterface.php b/lib/Service/Access/AuthorizationPolicyInterface.php new file mode 100644 index 0000000..593e134 --- /dev/null +++ b/lib/Service/Access/AuthorizationPolicyInterface.php @@ -0,0 +1,11 @@ + */ + private array $policies = []; + + /** + * @param array $policies + */ + public function __construct(array $policies) + { + foreach ($policies as $policy) { + if ($policy instanceof AuthorizationPolicyInterface) { + $this->policies[] = $policy; + } + } + } + + public function authorize(string $ability, array $context = []): AuthorizationDecision + { + foreach ($this->policies as $policy) { + if (!$policy->supports($ability)) { + continue; + } + + try { + return $policy->authorize($ability, $context); + } catch (Throwable) { + return AuthorizationDecision::deny(500, 'authorization_failed'); + } + } + + return AuthorizationDecision::deny(500, 'authorization_policy_missing'); + } +} + diff --git a/lib/Service/Access/DepartmentAuthorizationPolicy.php b/lib/Service/Access/DepartmentAuthorizationPolicy.php new file mode 100644 index 0000000..7487a04 --- /dev/null +++ b/lib/Service/Access/DepartmentAuthorizationPolicy.php @@ -0,0 +1,160 @@ + $this->authorizeAdminDepartmentsView($context), + self::ABILITY_ADMIN_DEPARTMENTS_CREATE => $this->authorizeAdminDepartmentsCreate($context), + self::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT => $this->authorizeAdminDepartmentsEditContext($context), + self::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT => $this->authorizeAdminDepartmentsEditSubmit($context), + self::ABILITY_ADMIN_DEPARTMENTS_DELETE => $this->authorizeAdminDepartmentsDelete($context), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminDepartmentsView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $allowedTenantIds = $this->scopeGateway->getUserTenantIds($actorUserId); + $isStrictScope = $this->scopeGateway->isStrict(); + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_create_department' => $this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_CREATE), + 'allowed_tenant_ids' => $allowedTenantIds, + 'is_strict_scope' => $isStrictScope, + ], + ]); + } + + private function authorizeAdminDepartmentsCreate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_CREATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $allowedTenantIds = $this->scopeGateway->getUserTenantIds($actorUserId); + $isStrictScope = $this->scopeGateway->isStrict(); + if ($isStrictScope && !$allowedTenantIds) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'allowed_tenant_ids' => $allowedTenantIds, + 'is_strict_scope' => $isStrictScope, + ], + ]); + } + + private function authorizeAdminDepartmentsEditContext(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetDepartmentId = $this->targetDepartmentId($context); + if ($actorUserId <= 0 || $targetDepartmentId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + if (!$this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + if (!$this->scopeGateway->canAccess('departments', $targetDepartmentId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_update_department' => $this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_UPDATE), + 'allowed_tenant_ids' => $this->scopeGateway->getUserTenantIds($actorUserId), + ], + ]); + } + + private function authorizeAdminDepartmentsEditSubmit(array $context): AuthorizationDecision + { + $contextDecision = $this->authorizeAdminDepartmentsEditContext($context); + if (!$contextDecision->isAllowed()) { + return $contextDecision; + } + + $capabilities = $this->capabilitiesFromDecision($contextDecision); + if (!($capabilities['can_update_department'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminDepartmentsDelete(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetDepartmentId = $this->targetDepartmentId($context); + if ($actorUserId <= 0 || $targetDepartmentId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + if (!$this->hasPermission($actorUserId, PermissionService::DEPARTMENTS_DELETE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + if (!$this->scopeGateway->canAccess('departments', $targetDepartmentId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + return AuthorizationDecision::allow(); + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function targetDepartmentId(array $context): int + { + return (int) ($context['target_department_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } + + private function capabilitiesFromDecision(AuthorizationDecision $decision): array + { + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; + } +} diff --git a/lib/Service/Access/PermissionAuthorizationPolicy.php b/lib/Service/Access/PermissionAuthorizationPolicy.php new file mode 100644 index 0000000..f2d284b --- /dev/null +++ b/lib/Service/Access/PermissionAuthorizationPolicy.php @@ -0,0 +1,129 @@ + $this->authorizeAdminPermissionsView($context), + self::ABILITY_ADMIN_PERMISSIONS_CREATE => $this->authorizeAdminPermissionsCreate($context), + self::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT => $this->authorizeAdminPermissionsEditContext($context), + self::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT => $this->authorizeAdminPermissionsEditSubmit($context), + self::ABILITY_ADMIN_PERMISSIONS_DELETE => $this->authorizeAdminPermissionsDelete($context), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminPermissionsView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::PERMISSIONS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_create_permission' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_CREATE), + ], + ]); + } + + private function authorizeAdminPermissionsCreate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::PERMISSIONS_CREATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeAdminPermissionsEditContext(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetPermissionId = (int) ($context['target_permission_id'] ?? 0); + if ($actorUserId <= 0 || $targetPermissionId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$this->hasPermission($actorUserId, PermissionService::PERMISSIONS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $targetIsSystem = (bool) ($context['target_is_system'] ?? false); + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_update_permission' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_UPDATE), + 'can_delete_permission' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_DELETE) && !$targetIsSystem, + ], + ]); + } + + private function authorizeAdminPermissionsEditSubmit(array $context): AuthorizationDecision + { + $contextDecision = $this->authorizeAdminPermissionsEditContext($context); + if (!$contextDecision->isAllowed()) { + return $contextDecision; + } + + $capabilities = $this->capabilitiesFromDecision($contextDecision); + if (!($capabilities['can_update_permission'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminPermissionsDelete(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::PERMISSIONS_DELETE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } + + private function capabilitiesFromDecision(AuthorizationDecision $decision): array + { + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; + } +} diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index 616e048..679d9f7 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -47,6 +47,7 @@ class PermissionService public const PERMISSIONS_DELETE = 'permissions.delete'; public const SETTINGS_VIEW = 'settings.view'; public const SETTINGS_UPDATE = 'settings.update'; + public const TENANT_SCOPE_GLOBAL = 'tenant.scope.global'; public const IMPORTS_VIEW = 'imports.view'; public const IMPORTS_AUDIT_VIEW = 'imports.audit.view'; public const JOBS_VIEW = 'jobs.view'; diff --git a/lib/Service/Access/RoleAuthorizationPolicy.php b/lib/Service/Access/RoleAuthorizationPolicy.php new file mode 100644 index 0000000..795798d --- /dev/null +++ b/lib/Service/Access/RoleAuthorizationPolicy.php @@ -0,0 +1,127 @@ + $this->authorizeAdminRolesView($context), + self::ABILITY_ADMIN_ROLES_CREATE => $this->authorizeAdminRolesCreate($context), + self::ABILITY_ADMIN_ROLES_EDIT_CONTEXT => $this->authorizeAdminRolesEditContext($context), + self::ABILITY_ADMIN_ROLES_EDIT_SUBMIT => $this->authorizeAdminRolesEditSubmit($context), + self::ABILITY_ADMIN_ROLES_DELETE => $this->authorizeAdminRolesDelete($context), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminRolesView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::ROLES_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_create_role' => $this->hasPermission($actorUserId, PermissionService::ROLES_CREATE), + ], + ]); + } + + private function authorizeAdminRolesCreate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::ROLES_CREATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeAdminRolesEditContext(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetRoleId = (int) ($context['target_role_id'] ?? 0); + if ($actorUserId <= 0 || $targetRoleId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$this->hasPermission($actorUserId, PermissionService::ROLES_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_edit_role' => $this->hasPermission($actorUserId, PermissionService::ROLES_UPDATE), + 'can_delete_role' => $this->hasPermission($actorUserId, PermissionService::ROLES_DELETE), + ], + ]); + } + + private function authorizeAdminRolesEditSubmit(array $context): AuthorizationDecision + { + $contextDecision = $this->authorizeAdminRolesEditContext($context); + if (!$contextDecision->isAllowed()) { + return $contextDecision; + } + + $capabilities = $this->capabilitiesFromDecision($contextDecision); + if (!($capabilities['can_edit_role'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminRolesDelete(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::ROLES_DELETE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } + + private function capabilitiesFromDecision(AuthorizationDecision $decision): array + { + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; + } +} diff --git a/lib/Service/Access/SettingsAuthorizationPolicy.php b/lib/Service/Access/SettingsAuthorizationPolicy.php new file mode 100644 index 0000000..3ec02b7 --- /dev/null +++ b/lib/Service/Access/SettingsAuthorizationPolicy.php @@ -0,0 +1,75 @@ + $this->authorizeAdminSettingsView($context), + self::ABILITY_ADMIN_SETTINGS_UPDATE, + self::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, + self::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE, + self::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN => $this->authorizeSettingsUpdatePermission($context), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminSettingsView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::SETTINGS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_update_settings' => $this->hasPermission($actorUserId, PermissionService::SETTINGS_UPDATE), + ], + ]); + } + + private function authorizeSettingsUpdatePermission(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::SETTINGS_UPDATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } +} diff --git a/lib/Service/Access/TenantAuthorizationPolicy.php b/lib/Service/Access/TenantAuthorizationPolicy.php new file mode 100644 index 0000000..3744c7b --- /dev/null +++ b/lib/Service/Access/TenantAuthorizationPolicy.php @@ -0,0 +1,233 @@ + $this->authorizeAdminTenantsView($context), + self::ABILITY_ADMIN_TENANTS_CREATE => $this->authorizeAdminTenantsCreate($context), + self::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT => $this->authorizeAdminTenantsEditContext($context), + self::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT => $this->authorizeAdminTenantsEditSubmit($context), + self::ABILITY_ADMIN_TENANTS_DELETE => $this->authorizeAdminTenantsDelete($context), + self::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE => $this->authorizeAdminTenantCustomFieldsManage($context), + self::ABILITY_ADMIN_TENANTS_AVATAR_VIEW => $this->authorizeAdminTenantAvatarView($context), + self::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE => $this->authorizeAdminTenantMediaUpdate($context), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminTenantsEditContext(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetTenantId = $this->targetTenantId($context); + if ($actorUserId <= 0 || $targetTenantId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$this->scopeGateway->canAccess('tenants', $targetTenantId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + $canViewTenant = $this->hasPermission($actorUserId, PermissionService::TENANTS_VIEW); + $canUpdateTenant = $this->hasPermission($actorUserId, PermissionService::TENANTS_UPDATE); + $canViewPage = $canViewTenant || $canUpdateTenant; + if (!$canViewPage) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $capabilities = [ + 'can_view_page' => $canViewPage, + 'can_update_tenant' => $canUpdateTenant, + 'can_manage_custom_fields' => $this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_MANAGE), + 'can_manage_sso' => $this->hasPermission($actorUserId, PermissionService::TENANTS_SSO_MANAGE), + 'can_delete_tenant' => $this->hasPermission($actorUserId, PermissionService::TENANTS_DELETE), + ]; + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminTenantsView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::TENANTS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_view_page' => true, + 'can_create_tenant' => $this->hasPermission($actorUserId, PermissionService::TENANTS_CREATE), + 'can_manage_custom_fields' => $this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_MANAGE), + 'can_manage_sso' => $this->hasPermission($actorUserId, PermissionService::TENANTS_SSO_MANAGE), + 'allowed_tenant_ids' => $this->scopeGateway->getUserTenantIds($actorUserId), + 'is_strict_scope' => $this->scopeGateway->isStrict(), + 'has_global_access' => $this->scopeGateway->hasGlobalAccess($actorUserId), + ], + ]); + } + + private function authorizeAdminTenantsCreate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::TENANTS_CREATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow([ + 'capabilities' => [ + 'can_manage_custom_fields' => $this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_MANAGE), + 'can_manage_sso' => $this->hasPermission($actorUserId, PermissionService::TENANTS_SSO_MANAGE), + ], + ]); + } + + private function authorizeAdminTenantsEditSubmit(array $context): AuthorizationDecision + { + $contextDecision = $this->authorizeAdminTenantsEditContext($context); + if (!$contextDecision->isAllowed()) { + return $contextDecision; + } + + $capabilities = $this->capabilitiesFromDecision($contextDecision); + if (!($capabilities['can_update_tenant'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $input = is_array($context['input'] ?? null) ? $context['input'] : []; + if ($this->containsSsoFields($input) && !($capabilities['can_manage_sso'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminTenantsDelete(array $context): AuthorizationDecision + { + return $this->authorizeTenantWithPermission($context, PermissionService::TENANTS_DELETE); + } + + private function authorizeAdminTenantCustomFieldsManage(array $context): AuthorizationDecision + { + return $this->authorizeTenantWithPermission($context, PermissionService::CUSTOM_FIELDS_MANAGE); + } + + private function authorizeAdminTenantAvatarView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::TENANTS_VIEW) + && !$this->hasPermission($actorUserId, PermissionService::TENANTS_UPDATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return $this->authorizeTenantScopeOnly($context); + } + + private function authorizeAdminTenantMediaUpdate(array $context): AuthorizationDecision + { + return $this->authorizeTenantWithPermission($context, PermissionService::TENANTS_UPDATE); + } + + private function authorizeTenantWithPermission(array $context, string $permissionKey): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, $permissionKey)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return $this->authorizeTenantScopeOnly($context); + } + + private function authorizeTenantScopeOnly(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetTenantId = $this->targetTenantId($context); + if ($actorUserId <= 0 || $targetTenantId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$this->scopeGateway->canAccess('tenants', $targetTenantId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + return AuthorizationDecision::allow(); + } + + private function containsSsoFields(array $input): bool + { + $ssoFields = [ + 'microsoft_enabled', + 'enforce_microsoft_login', + 'sync_profile_on_login', + 'sync_profile_fields', + 'entra_tenant_id', + 'allowed_domains', + 'use_shared_app', + 'client_id_override', + 'client_secret_override', + 'clear_client_secret_override', + ]; + + foreach ($ssoFields as $field) { + if (array_key_exists($field, $input)) { + return true; + } + } + + return false; + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function targetTenantId(array $context): int + { + return (int) ($context['target_tenant_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } + + private function capabilitiesFromDecision(AuthorizationDecision $decision): array + { + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; + } +} diff --git a/lib/Service/Access/UserAuthorizationPolicy.php b/lib/Service/Access/UserAuthorizationPolicy.php new file mode 100644 index 0000000..521f837 --- /dev/null +++ b/lib/Service/Access/UserAuthorizationPolicy.php @@ -0,0 +1,455 @@ + $this->authorizeAdminUsersView($context), + self::ABILITY_ADMIN_USERS_CREATE => $this->authorizeAdminUsersCreate($context), + self::ABILITY_ADMIN_USERS_EDIT_CONTEXT => $this->authorizeAdminUsersEditContext($context), + self::ABILITY_ADMIN_USERS_EDIT_SUBMIT => $this->authorizeAdminUsersEditSubmit($context), + self::ABILITY_ADMIN_USERS_ACTIVATE, + self::ABILITY_ADMIN_USERS_DEACTIVATE => $this->authorizeAdminUserStatusUpdate($context), + self::ABILITY_ADMIN_USERS_DELETE => $this->authorizeAdminUserDelete($context), + self::ABILITY_ADMIN_USERS_BULK => $this->authorizeAdminUsersBulk($context), + self::ABILITY_ADMIN_USERS_ACCESS_PDF => $this->authorizeAdminUserAccessPdf($context), + self::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK => $this->authorizeAdminUsersAccessPdfBulk($context), + self::ABILITY_ADMIN_USERS_AVATAR_UPLOAD, + self::ABILITY_ADMIN_USERS_AVATAR_DELETE => $this->authorizeAdminUsersAvatarMutate($context), + self::ABILITY_ADMIN_USERS_AVATAR_VIEW => $this->authorizeAdminUsersAvatarView($context), + self::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE => $this->authorizeAdminUsersApiTokensManage($context), + self::ABILITY_ADMIN_USERS_SEND_ACCESS, + self::ABILITY_ADMIN_USERS_FORGET_TOKENS => $this->authorizeAdminUserSelfOrUpdate($context), + self::ABILITY_API_USERS_INDEX_GET => $this->authorizeApiUsersIndexGet($context), + self::ABILITY_API_USERS_INDEX_POST => $this->authorizeApiUsersIndexPost($context), + self::ABILITY_API_USERS_SHOW_GET => $this->authorizeApiUsersShow($context, PermissionService::USERS_VIEW), + self::ABILITY_API_USERS_SHOW_UPDATE => $this->authorizeApiUsersUpdate($context), + self::ABILITY_API_USERS_SHOW_DELETE => $this->authorizeApiUsersShow($context, PermissionService::USERS_DELETE), + default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'), + }; + } + + private function authorizeAdminUsersView(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_VIEW, $context, false); + } + + private function authorizeAdminUsersCreate(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_CREATE, $context, false); + } + + private function authorizeAdminUsersEditContext(array $context): AuthorizationDecision + { + return $this->buildEditContextDecision($context); + } + + private function authorizeAdminUsersEditSubmit(array $context): AuthorizationDecision + { + $contextDecision = $this->buildEditContextDecision($context); + if (!$contextDecision->isAllowed()) { + return $contextDecision; + } + + $capabilities = $this->capabilitiesFromDecision($contextDecision); + if (!($capabilities['can_edit_user'] ?? false)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function authorizeAdminUserStatusUpdate(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_UPDATE, $context); + } + + private function authorizeAdminUserDelete(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_DELETE, $context); + } + + private function authorizeAdminUserAccessPdf(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context); + } + + private function authorizeAdminUsersAccessPdfBulk(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context, false); + } + + private function authorizeAdminUsersApiTokensManage(array $context): AuthorizationDecision + { + return $this->authorizeAdminUserWithPermission(PermissionService::API_TOKENS_MANAGE, $context); + } + + private function authorizeAdminUsersAvatarMutate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($actorUserId <= 0 || $targetUserId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $isOwnAccount = $actorUserId === $targetUserId; + if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); + if (!$canEditUser) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeAdminUsersAvatarView(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($actorUserId <= 0 || $targetUserId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if ($actorUserId === $targetUserId) { + return AuthorizationDecision::allow(); + } + + if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); + if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW) + && !$this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW) + && !$canEditUser) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeAdminUserSelfOrUpdate(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($targetUserId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if ($this->canEditUserForTarget($actorUserId, $targetUserId)) { + if ($targetUserId !== $actorUserId + && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + return AuthorizationDecision::allow(); + } + + return AuthorizationDecision::deny(403, 'forbidden'); + } + + private function authorizeAdminUsersBulk(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $action = strtolower(trim((string) ($context['bulk_action'] ?? ''))); + + $permission = match ($action) { + 'delete' => PermissionService::USERS_DELETE, + 'activate', 'deactivate', 'send-access' => PermissionService::USERS_UPDATE, + default => null, + }; + + if ($permission === null) { + return AuthorizationDecision::deny(400, 'invalid_action'); + } + if (!$this->hasPermission($actorUserId, $permission)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeApiUsersIndexGet(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + + private function authorizeApiUsersIndexPost(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, PermissionService::USERS_CREATE)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $input = is_array($context['input'] ?? null) ? $context['input'] : []; + $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); + if ($scopedTenantId <= 0) { + return AuthorizationDecision::allow(['input' => $input]); + } + + if ($this->containsOutOfScopeTenantId($input['tenant_ids'] ?? [], $scopedTenantId)) { + return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); + } + + $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); + if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { + return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); + } + + $input['tenant_ids'] = [$scopedTenantId]; + $input['primary_tenant_id'] = $scopedTenantId; + + return AuthorizationDecision::allow(['input' => $input]); + } + + private function authorizeApiUsersUpdate(array $context): AuthorizationDecision + { + $decision = $this->authorizeApiUsersShow($context, PermissionService::USERS_UPDATE); + if (!$decision->isAllowed()) { + return $decision; + } + + $input = is_array($context['input'] ?? null) ? $context['input'] : []; + $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); + if ($scopedTenantId <= 0) { + return AuthorizationDecision::allow(['input' => $input]); + } + + if (array_key_exists('tenant_ids', $input) + && $this->containsOutOfScopeTenantId($input['tenant_ids'], $scopedTenantId)) { + return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); + } + + if (array_key_exists('primary_tenant_id', $input)) { + $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); + if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { + return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden'); + } + } + + return AuthorizationDecision::allow(['input' => $input]); + } + + private function authorizeApiUsersShow(array $context, string $permissionKey): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, $permissionKey)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($targetUserId <= 0) { + return AuthorizationDecision::allow(); + } + + if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(404, 'not_found'); + } + + $scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0); + if ($scopedTenantId > 0 && !$this->scopeGateway->resourceBelongsToTenant('users', $targetUserId, $scopedTenantId)) { + return AuthorizationDecision::deny(404, 'not_found'); + } + + return AuthorizationDecision::allow(); + } + + private function containsOutOfScopeTenantId(mixed $tenantIdsRaw, int $scopedTenantId): bool + { + $tenantIds = $this->normalizeTenantIds($tenantIdsRaw); + if (!$tenantIds) { + return false; + } + + return array_diff($tenantIds, [$scopedTenantId]) !== []; + } + + private function normalizeTenantIds(mixed $tenantIdsRaw): array + { + if (!is_array($tenantIdsRaw)) { + $tenantIdsRaw = [$tenantIdsRaw]; + } + + $tenantIds = []; + foreach ($tenantIdsRaw as $value) { + $tenantId = (int) $value; + if ($tenantId > 0) { + $tenantIds[$tenantId] = $tenantId; + } + } + + return array_values($tenantIds); + } + + private function actorUserId(array $context): int + { + return (int) ($context['actor_user_id'] ?? 0); + } + + private function hasPermission(int $userId, string $permissionKey): bool + { + return $userId > 0 && $this->permissionGateway->userHas($userId, $permissionKey); + } + + private function canEditUserForTarget(int $actorUserId, int $targetUserId): bool + { + if ($actorUserId <= 0 || $targetUserId <= 0) { + return false; + } + + $canUpdateUser = $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE); + if ($targetUserId === $actorUserId) { + return $canUpdateUser || $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE); + } + + return $canUpdateUser; + } + + private function buildEditContextDecision(array $context): AuthorizationDecision + { + $actorUserId = $this->actorUserId($context); + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($actorUserId <= 0 || $targetUserId <= 0) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $isOwnAccount = $targetUserId === $actorUserId; + if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + $canViewUsers = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW); + $canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId); + $canViewPage = $canViewUsers || $canEditUser; + if (!$canViewPage) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + $canManageTenants = $this->hasPermission($actorUserId, PermissionService::TENANTS_UPDATE); + $allowedTenantIds = $canManageTenants ? null : $this->scopeGateway->getUserTenantIds($actorUserId); + $canViewUserAudit = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_AUDIT); + + $capabilities = [ + 'can_view_page' => $canViewPage, + 'can_view_users' => $canViewUsers, + 'can_edit_user' => $canEditUser, + 'can_edit_assignments' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS), + 'can_manage_tenants' => $canManageTenants, + 'can_edit_custom_field_values' => $canEditUser && ( + $this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES) + || ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE)) + ), + 'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE), + 'can_view_address_book' => $this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW), + 'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META), + 'can_view_user_audit' => $canViewUserAudit, + 'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF), + 'can_delete_user' => !$isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_DELETE), + 'can_view_security_artifacts' => $isOwnAccount || $canViewUserAudit, + 'can_view_permissions_table' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_VIEW), + 'is_own_account' => $isOwnAccount, + 'allowed_tenant_ids' => is_array($allowedTenantIds) + ? array_values(array_unique(array_map('intval', $allowedTenantIds))) + : null, + ]; + + return AuthorizationDecision::allow(['capabilities' => $capabilities]); + } + + private function capabilitiesFromDecision(AuthorizationDecision $decision): array + { + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; + } + + private function authorizeAdminUserWithPermission( + string $permissionKey, + array $context, + bool $checkTenantScope = true + ): AuthorizationDecision { + $actorUserId = $this->actorUserId($context); + if (!$this->hasPermission($actorUserId, $permissionKey)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$checkTenantScope) { + return AuthorizationDecision::allow(); + } + + $targetUserId = (int) ($context['target_user_id'] ?? 0); + if ($targetUserId > 0 + && $targetUserId !== $actorUserId + && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) { + return AuthorizationDecision::deny(403, 'permission_denied'); + } + + return AuthorizationDecision::allow(); + } +} diff --git a/lib/Service/Tenant/TenantScopeService.php b/lib/Service/Tenant/TenantScopeService.php index eb9566c..4864b57 100644 --- a/lib/Service/Tenant/TenantScopeService.php +++ b/lib/Service/Tenant/TenantScopeService.php @@ -164,7 +164,6 @@ class TenantScopeService return false; } - return $this->permissionGateway->userHas($userId, PermissionService::TENANTS_UPDATE) - || $this->permissionGateway->userHas($userId, PermissionService::SETTINGS_UPDATE); + return $this->permissionGateway->userHas($userId, PermissionService::TENANT_SCOPE_GLOBAL); } } diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index ba6657b..f632b49 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -443,6 +443,104 @@ class UserAccountService return ['ok' => true]; } + /** + * Self-service profile update (subset of fields, no admin-only changes). + * + * @param int $userId + * @param array $input + * @return array{ok: bool, errors?: list, form?: array} + */ + public function updateSelfProfile(int $userId, array $input): array + { + $existing = $this->userReadRepository->find($userId); + if (!$existing) { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + + $allowedFields = [ + 'first_name', 'last_name', 'profile_description', 'job_title', + 'phone', 'mobile', 'short_dial', + 'address', 'postal_code', 'city', 'country', 'region', + ]; + + $form = []; + foreach ($allowedFields as $field) { + $form[$field] = array_key_exists($field, $input) + ? trim((string) $input[$field]) + : ($existing[$field] ?? ''); + } + + $errors = []; + if ($form['first_name'] === '') { + $errors[] = t('First name cannot be empty'); + } + if ($form['last_name'] === '') { + $errors[] = t('Last name cannot be empty'); + } + + $locale = array_key_exists('locale', $input) + ? $this->normalizeLocale($input['locale']) + : ($existing['locale'] ?? ''); + $theme = array_key_exists('theme', $input) + ? $this->normalizeTheme($input['theme']) + : ($existing['theme'] ?? 'light'); + + // Tenant switch + $currentTenantId = null; + if (array_key_exists('current_tenant_uuid', $input)) { + $tenantUuid = trim((string) ($input['current_tenant_uuid'] ?? '')); + if ($tenantUuid !== '') { + $tenantService = directoryServicesFactory()->createTenantService(); + $tenant = $tenantService->findByUuid($tenantUuid); + if (!$tenant || !isset($tenant['id'])) { + $errors[] = t('Tenant not found'); + } else { + $userTenantIds = $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? []; + $userTenantIds = array_map(static fn(array $t): int => (int) ($t['id'] ?? 0), $userTenantIds); + $tenantId = (int) $tenant['id']; + if (!in_array($tenantId, $userTenantIds, true)) { + $errors[] = t('No access to this tenant'); + } else { + $currentTenantId = $tenantId; + } + } + } + } + + if ($errors) { + return ['ok' => false, 'errors' => $errors, 'form' => $form]; + } + + $updateData = [ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, + 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, + 'phone' => $form['phone'] !== '' ? $form['phone'] : null, + 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, + 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, + 'address' => $form['address'] !== '' ? $form['address'] : null, + 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, + 'city' => $form['city'] !== '' ? $form['city'] : null, + 'country' => $form['country'] !== '' ? $form['country'] : null, + 'region' => $form['region'] !== '' ? $form['region'] : null, + 'locale' => $locale, + 'theme' => $theme, + 'modified_by' => $userId, + ]; + + $updated = $this->userWriteRepository->update($userId, $updateData); + if (!$updated) { + return ['ok' => false, 'errors' => [t('Profile can not be updated')]]; + } + + if ($currentTenantId !== null) { + $this->userWriteRepository->setCurrentTenant($userId, $currentTenantId); + } + + return ['ok' => true, 'form' => $form]; + } + private function filterUuidsByTenantScope(array $uuids, int $currentUserId): array { $allowed = []; diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index 3188fdf..be59b0d 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -2,19 +2,24 @@ use MintyPHP\Buffer; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\DepartmentAuthorizationPolicy; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_CREATE); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$allowedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); -if (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict() && !$allowedTenantIds) { - Router::redirect('error/forbidden'); - return; +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_CREATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } +$capabilities = $decision->attribute('capabilities', []); +$allowedTenantIds = is_array($capabilities) ? array_values(array_unique(array_map('intval', (array) ($capabilities['allowed_tenant_ids'] ?? [])))) : []; +$isStrictScope = is_array($capabilities) ? (bool) ($capabilities['is_strict_scope'] ?? false) : false; $errors = []; $warnings = []; @@ -31,7 +36,7 @@ if ($allowedTenantIds) { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); -} elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { +} elseif ($isStrictScope) { $tenants = []; } diff --git a/pages/admin/departments/data().php b/pages/admin/departments/data().php index 512e18b..f551792 100644 --- a/pages/admin/departments/data().php +++ b/pages/admin/departments/data().php @@ -1,16 +1,24 @@ createDirectoryScopeGateway()->getUserTenantIds($currentUserId); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} +$capabilities = $decision->attribute('capabilities', []); +$tenantIds = is_array($capabilities) ? array_values(array_unique(array_map('intval', (array) ($capabilities['allowed_tenant_ids'] ?? [])))) : []; $limit = (int) ($_GET['limit'] ?? 10); $offset = (int) ($_GET['offset'] ?? 0); diff --git a/pages/admin/departments/delete($id).php b/pages/admin/departments/delete($id).php index 6c1cbbc..290ce87 100644 --- a/pages/admin/departments/delete($id).php +++ b/pages/admin/departments/delete($id).php @@ -1,22 +1,35 @@ createAuthorizationService(); $uuid = trim((string) ($id ?? '')); if ($uuid === '') { Router::redirect('admin/departments'); + return; } $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$department = $uuid !== '' ? directoryServicesFactory()->createDepartmentService()->findByUuid($uuid) : null; -if ($department && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('departments', (int) ($department['id'] ?? 0), $currentUserId)) { +$department = directoryServicesFactory()->createDepartmentService()->findByUuid($uuid); +if (!$department) { + Flash::error('Department not found', 'admin/departments', 'department_not_found'); + Router::redirect('admin/departments'); + return; +} + +$departmentId = (int) ($department['id'] ?? 0); +$decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_DELETE, [ + 'actor_user_id' => $currentUserId, + 'target_department_id' => $departmentId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } @@ -25,6 +38,7 @@ $result = directoryServicesFactory()->createDepartmentService()->deleteByUuid($u if (!($result['ok'] ?? false)) { Flash::error('Department not found', 'admin/departments', 'department_not_found'); Router::redirect('admin/departments'); + return; } if ($currentUserId > 0) { diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index 4a4b29d..b793aae 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -2,20 +2,20 @@ use MintyPHP\Buffer; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\DepartmentAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); if ($currentUserId > 0) { permissionGateway()->getUserPermissions($currentUserId); } $userAccountService = (new UserServicesFactory())->createUserAccountService(); -$allowedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); $uuid = trim((string) ($id ?? '')); $department = $uuid !== '' ? directoryServicesFactory()->createDepartmentService()->findByUuid($uuid) : null; @@ -25,10 +25,30 @@ if (!$department) { } $departmentId = (int) ($department['id'] ?? 0); -if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('departments', $departmentId, $currentUserId)) { +$contextDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT, [ + 'actor_user_id' => $currentUserId, + 'target_department_id' => $departmentId, +]); +if (!$contextDecision->isAllowed()) { Router::redirect('error/forbidden'); return; } + +$capabilities = $contextDecision->attribute('capabilities', []); +if (!is_array($capabilities)) { + $capabilities = []; +} +$canViewPage = (bool) ($capabilities['can_view_page'] ?? false); +$canUpdateDepartment = (bool) ($capabilities['can_update_department'] ?? false); +$allowedTenantIdsRaw = $capabilities['allowed_tenant_ids'] ?? []; +$allowedTenantIds = is_array($allowedTenantIdsRaw) + ? array_values(array_unique(array_filter(array_map('intval', $allowedTenantIdsRaw), static fn (int $tenantId): bool => $tenantId > 0))) + : []; +if (!$canViewPage) { + Router::redirect('error/forbidden'); + return; +} + $creatorId = (int) ($department['created_by'] ?? 0); if ($creatorId > 0) { $creator = $userAccountService->findById($creatorId); @@ -66,13 +86,31 @@ if ($allowedTenantIds) { } if (isset($_POST['description'])) { - if (!permissionGateway()->userHas($currentUserId, PermissionService::DEPARTMENTS_UPDATE)) { + $submitDecision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT, [ + 'actor_user_id' => $currentUserId, + 'target_department_id' => $departmentId, + 'input' => $_POST, + ]); + if (!$submitDecision->isAllowed()) { Router::redirect('error/forbidden'); return; } + + $submitCapabilities = $submitDecision->attribute('capabilities', []); + if (is_array($submitCapabilities)) { + $canUpdateDepartment = (bool) ($submitCapabilities['can_update_department'] ?? $canUpdateDepartment); + } + if (!$canUpdateDepartment) { + Router::redirect('error/forbidden'); + return; + } + $selectedTenantId = (int) ($_POST['tenant_id'] ?? 0); - $selectedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->filterTenantIdsForUser([$selectedTenantId], $currentUserId); - $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); + if ($allowedTenantIds) { + $selectedTenantId = in_array($selectedTenantId, $allowedTenantIds, true) ? $selectedTenantId : 0; + } elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { + $selectedTenantId = 0; + } $input = $_POST; $input['tenant_id'] = $selectedTenantId; diff --git a/pages/admin/departments/index().php b/pages/admin/departments/index().php index dd17a2d..d83baa3 100644 --- a/pages/admin/departments/index().php +++ b/pages/admin/departments/index().php @@ -1,26 +1,33 @@ 0) { - permissionGateway()->getUserPermissions($currentUserId); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(DepartmentAuthorizationPolicy::ABILITY_ADMIN_DEPARTMENTS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } +$capabilities = $decision->attribute('capabilities', []); +$allowedTenantIds = is_array($capabilities) ? array_values(array_unique(array_map('intval', (array) ($capabilities['allowed_tenant_ids'] ?? [])))) : []; +$isStrictScope = is_array($capabilities) ? (bool) ($capabilities['is_strict_scope'] ?? false) : false; +$canCreateDepartments = is_array($capabilities) ? (bool) ($capabilities['can_create_department'] ?? false) : false; $tenants = directoryServicesFactory()->createTenantService()->list(); -$allowedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); if ($allowedTenantIds) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); -} elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { +} elseif ($isStrictScope) { $tenants = []; } usort($tenants, static function ($a, $b) { diff --git a/pages/admin/departments/index(default).phtml b/pages/admin/departments/index(default).phtml index 3b4f3bc..8a43e8c 100644 --- a/pages/admin/departments/index(default).phtml +++ b/pages/admin/departments/index(default).phtml @@ -1,7 +1,7 @@ 1; diff --git a/pages/admin/permissions/create().php b/pages/admin/permissions/create().php index 0415de5..68fbd93 100644 --- a/pages/admin/permissions/create().php +++ b/pages/admin/permissions/create().php @@ -4,10 +4,19 @@ use MintyPHP\Buffer; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\PermissionAuthorizationPolicy; Guard::requireLogin(); -Guard::requirePermission(PermissionService::PERMISSIONS_CREATE); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_CREATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} $errors = []; $form = [ diff --git a/pages/admin/permissions/data().php b/pages/admin/permissions/data().php index 34d043e..e391fc2 100644 --- a/pages/admin/permissions/data().php +++ b/pages/admin/permissions/data().php @@ -2,13 +2,18 @@ use MintyPHP\Router; use MintyPHP\Support\Guard; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\PermissionAuthorizationPolicy; Guard::requireLogin(); -Guard::requirePermission(PermissionService::PERMISSIONS_VIEW); -if (!isset($_SESSION['user'])) { - http_response_code(401); - Router::json(['data' => [], 'total' => 0]); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } $limit = (int) ($_GET['limit'] ?? 10); diff --git a/pages/admin/permissions/delete($id).php b/pages/admin/permissions/delete($id).php index 819e6a8..c35648d 100644 --- a/pages/admin/permissions/delete($id).php +++ b/pages/admin/permissions/delete($id).php @@ -3,14 +3,24 @@ use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\PermissionAuthorizationPolicy; Guard::requireLogin(); -Guard::requirePermission(PermissionService::PERMISSIONS_DELETE); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_DELETE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} $id = (int) ($id ?? 0); if ($id <= 0) { Router::redirect('admin/permissions'); + return; } $result = permissionGateway()->deleteById($id); diff --git a/pages/admin/permissions/edit($id).php b/pages/admin/permissions/edit($id).php index 3790cf8..806f458 100644 --- a/pages/admin/permissions/edit($id).php +++ b/pages/admin/permissions/edit($id).php @@ -4,17 +4,15 @@ use MintyPHP\Buffer; use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Router; use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\PermissionAuthorizationPolicy; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermission(PermissionService::PERMISSIONS_VIEW); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if ($currentUserId > 0) { - permissionGateway()->getUserPermissions($currentUserId); -} +$accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); $id = (int) ($id ?? 0); $permission = $id > 0 ? permissionGateway()->find($id) : null; @@ -22,18 +20,33 @@ if (!$permission) { Flash::error('Permission not found', 'admin/permissions', 'permission_not_found'); Router::redirect('admin/permissions'); } +$isSystemPermission = (int) ($permission['is_system'] ?? 0) === 1; +$contextDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT, [ + 'actor_user_id' => $currentUserId, + 'target_permission_id' => $id, + 'target_is_system' => $isSystemPermission, +]); +if (!$contextDecision->isAllowed()) { + Guard::deny(); +} +$capabilities = $contextDecision->attribute('capabilities', []); +$canUpdatePermission = is_array($capabilities) ? (bool) ($capabilities['can_update_permission'] ?? false) : false; +$canDeletePermission = is_array($capabilities) ? (bool) ($capabilities['can_delete_permission'] ?? false) : false; $errors = []; $form = $permission; $roles = (new RoleRepository())->listActive(); -$rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository(); +$rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); $selectedRoleIds = $rolePermissionRepository->listRoleIdsByPermissionId($id); if (isset($_POST['key'])) { - if (!permissionGateway()->userHas($currentUserId, PermissionService::PERMISSIONS_UPDATE)) { - Flash::error('Permission denied', "admin/permissions/edit/{$id}", 'permission_denied'); - Router::redirect("admin/permissions/edit/{$id}"); - return; + $submitDecision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT, [ + 'actor_user_id' => $currentUserId, + 'target_permission_id' => $id, + 'target_is_system' => $isSystemPermission, + ]); + if (!$submitDecision->isAllowed()) { + Guard::deny(); } $result = permissionGateway()->updateFromAdmin($id, $_POST); $form = $result['form'] ?? $form; diff --git a/pages/admin/permissions/edit(default).phtml b/pages/admin/permissions/edit(default).phtml index 466c12e..2e9cbe3 100644 --- a/pages/admin/permissions/edit(default).phtml +++ b/pages/admin/permissions/edit(default).phtml @@ -4,10 +4,15 @@ * @var array $errors * @var array $form * @var array $permission + * @var bool $canUpdatePermission + * @var bool $canDeletePermission */ use MintyPHP\Session; +$canUpdatePermission = (bool) ($canUpdatePermission ?? false); +$canDeletePermission = (bool) ($canDeletePermission ?? false); + ?>
@@ -22,7 +27,7 @@ use MintyPHP\Session; 'title' => t('Edit permission'), 'backHref' => 'admin/permissions', 'backTitle' => t('Cancel'), - 'actions' => can('permissions.update') ? [ + 'actions' => $canUpdatePermission ? [ [ 'form' => 'permission-form', 'name' => 'action', @@ -52,10 +57,10 @@ use MintyPHP\Session; 0) { - permissionGateway()->getUserPermissions($currentUserId); +$accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); +$decision = $authorizationService->authorize(PermissionAuthorizationPolicy::ABILITY_ADMIN_PERMISSIONS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } +$capabilities = $decision->attribute('capabilities', []); +$canCreatePermissions = is_array($capabilities) ? (bool) ($capabilities['can_create_permission'] ?? false) : false; Buffer::set('title', t('Permissions')); Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); diff --git a/pages/admin/permissions/index(default).phtml b/pages/admin/permissions/index(default).phtml index e7500f0..24718d1 100644 --- a/pages/admin/permissions/index(default).phtml +++ b/pages/admin/permissions/index(default).phtml @@ -1,8 +1,7 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_CREATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} $errors = []; $form = [ @@ -16,14 +25,12 @@ $form = [ 'code' => '', 'active' => 1, ]; -$accessServicesFactory = new AccessServicesFactory(); $permissionRepository = $accessServicesFactory->createPermissionRepository(); $rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); $permissions = $permissionRepository->listActive(); $selectedPermissionIds = []; if (isset($_POST['description'])) { - $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $result = directoryServicesFactory()->createRoleService()->createFromAdmin($_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; diff --git a/pages/admin/roles/data().php b/pages/admin/roles/data().php index 8c37706..7369ab4 100644 --- a/pages/admin/roles/data().php +++ b/pages/admin/roles/data().php @@ -2,13 +2,22 @@ use MintyPHP\Router; use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\RoleAuthorizationPolicy; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermission(PermissionService::ROLES_VIEW); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); +$decision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} $limit = (int) ($_GET['limit'] ?? 10); $offset = (int) ($_GET['offset'] ?? 0); @@ -29,7 +38,7 @@ $result = directoryServicesFactory()->createRoleService()->listPaged([ $settingsFactory = new SettingServicesFactory(); $settingGateway = $settingsFactory->createSettingGateway(); $defaultRoleId = $settingGateway->getDefaultRoleId(); -$rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository(); +$rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); $roleIds = []; foreach ($result['rows'] as $roleRow) { $roleId = (int) ($roleRow['id'] ?? 0); diff --git a/pages/admin/roles/delete($id).php b/pages/admin/roles/delete($id).php index b8631cc..f4a907c 100644 --- a/pages/admin/roles/delete($id).php +++ b/pages/admin/roles/delete($id).php @@ -1,16 +1,26 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_DELETE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} $uuid = trim((string) ($id ?? '')); if ($uuid === '') { Router::redirect('admin/roles'); + return; } $result = directoryServicesFactory()->createRoleService()->deleteByUuid($uuid); diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php index f08b1c4..3b92c78 100644 --- a/pages/admin/roles/edit($id).php +++ b/pages/admin/roles/edit($id).php @@ -3,19 +3,16 @@ use MintyPHP\Buffer; use MintyPHP\Router; use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\RoleAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermission(PermissionService::ROLES_VIEW); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if ($currentUserId > 0) { - permissionGateway()->getUserPermissions($currentUserId); -} $accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); $permissionRepository = $accessServicesFactory->createPermissionRepository(); $rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); @@ -28,6 +25,17 @@ if (!$role) { } $roleId = (int) ($role['id'] ?? 0); +$contextDecision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_CONTEXT, [ + 'actor_user_id' => $currentUserId, + 'target_role_id' => $roleId, +]); +if (!$contextDecision->isAllowed()) { + Guard::deny(); +} +$capabilities = $contextDecision->attribute('capabilities', []); +$canUpdateRole = is_array($capabilities) ? (bool) ($capabilities['can_edit_role'] ?? false) : false; +$canDeleteRole = is_array($capabilities) ? (bool) ($capabilities['can_delete_role'] ?? false) : false; + $creatorId = (int) ($role['created_by'] ?? 0); if ($creatorId > 0) { $creator = $userAccountService->findById($creatorId); @@ -53,10 +61,12 @@ $permissions = $permissionRepository->listActive(); $selectedPermissionIds = $rolePermissionRepository->listPermissionIdsByRoleId($roleId); if (isset($_POST['description'])) { - if (!permissionGateway()->userHas($currentUserId, PermissionService::ROLES_UPDATE)) { - Flash::error('Permission denied', "admin/roles/edit/{$uuid}", 'permission_denied'); - Router::redirect("admin/roles/edit/{$uuid}"); - return; + $submitDecision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_EDIT_SUBMIT, [ + 'actor_user_id' => $currentUserId, + 'target_role_id' => $roleId, + ]); + if (!$submitDecision->isAllowed()) { + Guard::deny(); } $result = directoryServicesFactory()->createRoleService()->updateFromAdmin($roleId, $_POST, $currentUserId); $form = $result['form'] ?? $form; diff --git a/pages/admin/roles/edit(default).phtml b/pages/admin/roles/edit(default).phtml index cac1891..b727f0b 100644 --- a/pages/admin/roles/edit(default).phtml +++ b/pages/admin/roles/edit(default).phtml @@ -4,12 +4,16 @@ * @var array $errors * @var array $form * @var array $role + * @var bool $canUpdateRole + * @var bool $canDeleteRole */ use MintyPHP\Session; $values = $form ?? $role ?? []; -$isReadOnly = !can('roles.update'); +$canUpdateRole = (bool) ($canUpdateRole ?? false); +$canDeleteRole = (bool) ($canDeleteRole ?? false); +$isReadOnly = !$canUpdateRole; ?> @@ -26,7 +30,7 @@ $isReadOnly = !can('roles.update'); 'title' => t('Edit role'), 'backHref' => 'admin/roles', 'backTitle' => t('Cancel'), - 'actions' => can('roles.update') ? [ + 'actions' => $canUpdateRole ? [ [ 'form' => 'role-form', 'name' => 'action', @@ -60,7 +64,7 @@ $isReadOnly = !can('roles.update'); 0) { - permissionGateway()->getUserPermissions($currentUserId); +$accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); +$decision = $authorizationService->authorize(RoleAuthorizationPolicy::ABILITY_ADMIN_ROLES_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } +$capabilities = $decision->attribute('capabilities', []); +$canCreateRoles = is_array($capabilities) ? (bool) ($capabilities['can_create_role'] ?? false) : false; Buffer::set('title', t('Roles')); Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); diff --git a/pages/admin/roles/index(default).phtml b/pages/admin/roles/index(default).phtml index 0adff21..8326cd7 100644 --- a/pages/admin/roles/index(default).phtml +++ b/pages/admin/roles/index(default).phtml @@ -1,7 +1,7 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if ($_SERVER['REQUEST_METHOD'] !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/favicon().php b/pages/admin/settings/favicon().php index f829b23..bfcdfc9 100644 --- a/pages/admin/settings/favicon().php +++ b/pages/admin/settings/favicon().php @@ -1,13 +1,22 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/favicon-delete().php b/pages/admin/settings/favicon-delete().php index 11063b6..812e59e 100644 --- a/pages/admin/settings/favicon-delete().php +++ b/pages/admin/settings/favicon-delete().php @@ -1,13 +1,22 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/index().php b/pages/admin/settings/index().php index 8475766..9591c8c 100644 --- a/pages/admin/settings/index().php +++ b/pages/admin/settings/index().php @@ -4,14 +4,25 @@ use MintyPHP\Buffer; use MintyPHP\Repository\Auth\ApiTokenRepository; use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\SettingsAuthorizationPolicy; use MintyPHP\Service\Settings\SettingService; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermission(PermissionService::SETTINGS_VIEW); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$viewDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$viewDecision->isAllowed()) { + Guard::deny(); +} +$viewCapabilities = $viewDecision->attribute('capabilities', []); +$canUpdateSettings = is_array($viewCapabilities) ? (bool) ($viewCapabilities['can_update_settings'] ?? false) : false; $rememberTokenRepository = new RememberTokenRepository(); $apiTokenRepository = new ApiTokenRepository(); @@ -88,7 +99,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!isset($_POST['settings_submit'])) { Router::redirect('admin/settings'); } - Guard::requirePermission(PermissionService::SETTINGS_UPDATE); + $updateDecision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_UPDATE, [ + 'actor_user_id' => $currentUserId, + ]); + if (!$updateDecision->isAllowed()) { + Guard::deny(); + } $defaultTenantId = (int) ($_POST['default_tenant_id'] ?? 0); $defaultRoleId = (int) ($_POST['default_role_id'] ?? 0); $defaultDepartmentId = (int) ($_POST['default_department_id'] ?? 0); diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml index 0d74ccd..39d5f46 100644 --- a/pages/admin/settings/index(default).phtml +++ b/pages/admin/settings/index(default).phtml @@ -6,6 +6,7 @@ * @var array $departments * @var array $values * @var array $settings + * @var bool $canUpdateSettings */ use MintyPHP\Session; @@ -66,7 +67,7 @@ $locales = defined('APP_LOCALES') ? APP_LOCALES : []; $brandingServicesFactory = new BrandingServicesFactory(); $hasLogo = $brandingServicesFactory->createBrandingLogoService()->hasLogo(); $hasFavicon = $brandingServicesFactory->createBrandingFaviconService()->hasFavicon(); -$canUpdateSettings = can('settings.update'); +$canUpdateSettings = (bool) ($canUpdateSettings ?? false); $isReadOnly = !$canUpdateSettings; $readonlyAttr = $isReadOnly ? 'readonly' : ''; $disabledAttr = $isReadOnly ? 'disabled' : ''; diff --git a/pages/admin/settings/logo().php b/pages/admin/settings/logo().php index cdf2606..6c1c5f6 100644 --- a/pages/admin/settings/logo().php +++ b/pages/admin/settings/logo().php @@ -1,13 +1,22 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/logo-delete().php b/pages/admin/settings/logo-delete().php index 6c7c8c2..c82c3fd 100644 --- a/pages/admin/settings/logo-delete().php +++ b/pages/admin/settings/logo-delete().php @@ -1,13 +1,22 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/revoke-api-tokens().php b/pages/admin/settings/revoke-api-tokens().php index bf20032..699e81b 100644 --- a/pages/admin/settings/revoke-api-tokens().php +++ b/pages/admin/settings/revoke-api-tokens().php @@ -1,13 +1,22 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if ($_SERVER['REQUEST_METHOD'] !== 'POST') { Router::redirect('admin/settings'); diff --git a/pages/admin/settings/run-user-lifecycle().php b/pages/admin/settings/run-user-lifecycle().php index 828f44c..f79a61b 100644 --- a/pages/admin/settings/run-user-lifecycle().php +++ b/pages/admin/settings/run-user-lifecycle().php @@ -1,20 +1,29 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(SettingsAuthorizationPolicy::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} if ($_SERVER['REQUEST_METHOD'] !== 'POST') { Router::redirect('admin/settings'); } $factory = new SchedulerServicesFactory(); -$result = $factory->createUserLifecycleService()->run((int) ($_SESSION['user']['id'] ?? 0)); +$result = $factory->createUserLifecycleService()->run($currentUserId); if (!($result['ok'] ?? false)) { $error = (string) ($result['error'] ?? 'unexpected_error'); if ($error === 'lock_not_acquired') { diff --git a/pages/admin/tenants/avatar($id).php b/pages/admin/tenants/avatar($id).php index 0fda853..8e40304 100644 --- a/pages/admin/tenants/avatar($id).php +++ b/pages/admin/tenants/avatar($id).php @@ -1,13 +1,14 @@ createAuthorizationService(); $tenantAvatarService = (new TenantServicesFactory())->createTenantAvatarService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -22,6 +23,18 @@ if (!$tenantAvatarService->isValidUuid($uuid)) { return; } +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +$tenantId = (int) ($tenant['id'] ?? 0); +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $result = $tenantAvatarService->saveUpload($uuid, $_FILES['avatar'] ?? []); if (!($result['ok'] ?? false)) { $error = $result['error'] ?? t('Upload failed'); diff --git a/pages/admin/tenants/avatar-delete($id).php b/pages/admin/tenants/avatar-delete($id).php index 2e3b24c..fc62087 100644 --- a/pages/admin/tenants/avatar-delete($id).php +++ b/pages/admin/tenants/avatar-delete($id).php @@ -1,13 +1,14 @@ createAuthorizationService(); $tenantAvatarService = (new TenantServicesFactory())->createTenantAvatarService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -22,6 +23,18 @@ if (!$tenantAvatarService->isValidUuid($uuid)) { return; } +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +$tenantId = (int) ($tenant['id'] ?? 0); +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $tenantAvatarService->delete($uuid); Flash::success('Avatar removed', "admin/tenants/edit/{$uuid}", 'tenant_avatar_removed'); Router::redirect("admin/tenants/edit/{$uuid}"); diff --git a/pages/admin/tenants/avatar-file().php b/pages/admin/tenants/avatar-file().php index f87f918..8a79458 100644 --- a/pages/admin/tenants/avatar-file().php +++ b/pages/admin/tenants/avatar-file().php @@ -1,11 +1,14 @@ createAuthorizationService(); $tenantAvatarService = (new TenantServicesFactory())->createTenantAvatarService(); $uuid = trim((string) ($_GET['uuid'] ?? '')); @@ -22,7 +25,11 @@ if ($tenantId <= 0) { http_response_code(404); return; } -if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('tenants', $tenantId, $currentUserId)) { +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_AVATAR_VIEW, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { http_response_code(403); return; } diff --git a/pages/admin/tenants/create().php b/pages/admin/tenants/create().php index 31bf3eb..f6f9f9f 100644 --- a/pages/admin/tenants/create().php +++ b/pages/admin/tenants/create().php @@ -2,16 +2,25 @@ use MintyPHP\Buffer; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\TenantAuthorizationPolicy; use MintyPHP\Service\Auth\AuthServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::TENANTS_CREATE); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$canManageSso = permissionGateway()->userHas($currentUserId, PermissionService::TENANTS_SSO_MANAGE); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CREATE, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} +$capabilities = $decision->attribute('capabilities', []); +$canManageSso = is_array($capabilities) ? (bool) ($capabilities['can_manage_sso'] ?? false) : false; +$canManageCustomFields = is_array($capabilities) ? (bool) ($capabilities['can_manage_custom_fields'] ?? false) : false; $tenantSsoService = (new AuthServicesFactory())->createTenantSsoService(); $errors = []; diff --git a/pages/admin/tenants/create(default).phtml b/pages/admin/tenants/create(default).phtml index dc4bde8..e95f705 100644 --- a/pages/admin/tenants/create(default).phtml +++ b/pages/admin/tenants/create(default).phtml @@ -3,6 +3,8 @@ /** * @var array $errors * @var array $form + * @var bool $canManageCustomFields + * @var bool $canManageSso */ ?> @@ -50,8 +52,8 @@ createAuthorizationService(); $tenantUuid = trim((string) ($id ?? '')); $tenant = $tenantUuid !== '' ? directoryServicesFactory()->createTenantService()->findByUuid($tenantUuid) : null; @@ -20,7 +21,11 @@ if (!$tenant) { $tenantId = (int) ($tenant['id'] ?? 0); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('tenants', $tenantId, $currentUserId)) { +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/tenants/custom-field-delete($id).php b/pages/admin/tenants/custom-field-delete($id).php index 5008f15..3bd53b2 100644 --- a/pages/admin/tenants/custom-field-delete($id).php +++ b/pages/admin/tenants/custom-field-delete($id).php @@ -1,14 +1,15 @@ createAuthorizationService(); $fieldUuid = trim((string) ($id ?? '')); $tenantId = TenantCustomFieldService::definitionTenantIdByUuid($fieldUuid); @@ -19,7 +20,11 @@ if ($tenantId <= 0) { } $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('tenants', $tenantId, $currentUserId)) { +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/tenants/custom-field-update($id).php b/pages/admin/tenants/custom-field-update($id).php index 1cfe6ad..891a209 100644 --- a/pages/admin/tenants/custom-field-update($id).php +++ b/pages/admin/tenants/custom-field-update($id).php @@ -1,14 +1,15 @@ createAuthorizationService(); $fieldUuid = trim((string) ($id ?? '')); $tenantId = TenantCustomFieldService::definitionTenantIdByUuid($fieldUuid); @@ -19,7 +20,11 @@ if ($tenantId <= 0) { } $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('tenants', $tenantId, $currentUserId)) { +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/tenants/data().php b/pages/admin/tenants/data().php index 84a841a..ccdf099 100644 --- a/pages/admin/tenants/data().php +++ b/pages/admin/tenants/data().php @@ -2,13 +2,26 @@ use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\TenantAuthorizationPolicy; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW); + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); +} +$capabilities = $decision->attribute('capabilities', []); +$allowedTenantIds = is_array($capabilities) ? array_values(array_unique(array_map('intval', (array) ($capabilities['allowed_tenant_ids'] ?? [])))) : []; +$isStrictScope = is_array($capabilities) ? (bool) ($capabilities['is_strict_scope'] ?? false) : false; +$hasGlobalAccess = is_array($capabilities) ? (bool) ($capabilities['has_global_access'] ?? false) : false; $limit = (int) ($_GET['limit'] ?? 10); $offset = (int) ($_GET['offset'] ?? 0); @@ -100,21 +113,30 @@ if ($globalDefaultTheme === null || !isset($themes[$globalDefaultTheme])) { $globalDefaultTheme = isset($themes[$envTheme]) ? $envTheme : 'light'; } -$result = directoryServicesFactory()->createTenantService()->listPaged([ +$listOptions = [ 'limit' => $limit, 'offset' => $offset, 'search' => $search, 'order' => in_array($order, $computedOrderKeys, true) ? 'description' : $order, 'dir' => $dir, -]); +]; +if (!$hasGlobalAccess) { + if ($allowedTenantIds) { + $listOptions['tenantIds'] = $allowedTenantIds; + } elseif ($isStrictScope) { + $listOptions['tenantIds'] = []; + } +} + +$result = directoryServicesFactory()->createTenantService()->listPaged($listOptions); $rows = []; $total = (int) ($result['total'] ?? 0); if (in_array($order, $computedOrderKeys, true)) { $fullResult = directoryServicesFactory()->createTenantService()->listPaged([ + ...$listOptions, 'limit' => max(1, $total), 'offset' => 0, - 'search' => $search, 'order' => 'description', 'dir' => 'asc', ]); diff --git a/pages/admin/tenants/delete($id).php b/pages/admin/tenants/delete($id).php index 8f05843..ff131ca 100644 --- a/pages/admin/tenants/delete($id).php +++ b/pages/admin/tenants/delete($id).php @@ -1,17 +1,37 @@ createAuthorizationService(); $uuid = trim((string) ($id ?? '')); if ($uuid === '') { Router::redirect('admin/tenants'); + return; +} + +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +if (!$tenant) { + Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found'); + Router::redirect('admin/tenants'); + return; +} + +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$tenantId = (int) ($tenant['id'] ?? 0); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_DELETE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; } $result = directoryServicesFactory()->createTenantService()->deleteByUuid($uuid); @@ -19,13 +39,14 @@ if (!($result['ok'] ?? false)) { if (($result['error'] ?? '') === 'tenant_has_departments') { Flash::error('Tenant can not be deleted while departments are assigned', 'admin/tenants', 'tenant_has_departments'); Router::redirect('admin/tenants'); + return; } Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found'); Router::redirect('admin/tenants'); + return; } // Refresh session tenant data (removes deleted tenant, switches if it was current) -$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); if ($currentUserId > 0) { (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($currentUserId); } diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index c1443d3..bd96d0e 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -2,14 +2,14 @@ use MintyPHP\Buffer; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\TenantAuthorizationPolicy; use MintyPHP\Service\Auth\AuthServicesFactory; use MintyPHP\Service\CustomField\TenantCustomFieldService; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); if ($currentUserId > 0) { @@ -27,8 +27,30 @@ if (!$tenant) { $tenantId = (int) ($tenant['id'] ?? 0); $authServicesFactory = new AuthServicesFactory(); $tenantSsoService = $authServicesFactory->createTenantSsoService(); -$canManageCustomFields = permissionGateway()->userHas($currentUserId, PermissionService::CUSTOM_FIELDS_MANAGE); -$canManageSso = permissionGateway()->userHas($currentUserId, PermissionService::TENANTS_SSO_MANAGE); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$contextDecision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$contextDecision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + +$capabilities = $contextDecision->attribute('capabilities', []); +if (!is_array($capabilities)) { + $capabilities = []; +} + +$canViewPage = (bool) ($capabilities['can_view_page'] ?? false); +$canUpdateTenant = (bool) ($capabilities['can_update_tenant'] ?? false); +$canManageCustomFields = (bool) ($capabilities['can_manage_custom_fields'] ?? false); +$canManageSso = (bool) ($capabilities['can_manage_sso'] ?? false); +if (!$canViewPage) { + Router::redirect('error/forbidden'); + return; +} + $customFieldDefinitions = $canManageCustomFields ? TenantCustomFieldService::listForTenant($tenantId) : []; @@ -66,28 +88,27 @@ $errors = []; $form = array_merge($tenant, $ssoConfig); if (isset($_POST['description'])) { - if (!permissionGateway()->userHas($currentUserId, PermissionService::TENANTS_UPDATE)) { + $submitDecision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, + 'input' => $_POST, + ]); + if (!$submitDecision->isAllowed()) { Router::redirect('error/forbidden'); return; } - $ssoFields = [ - 'microsoft_enabled', - 'enforce_microsoft_login', - 'sync_profile_on_login', - 'sync_profile_fields', - 'entra_tenant_id', - 'allowed_domains', - 'use_shared_app', - 'client_id_override', - 'client_secret_override', - 'clear_client_secret_override', - ]; - foreach ($ssoFields as $ssoField) { - if (array_key_exists($ssoField, $_POST) && !$canManageSso) { - Router::redirect('error/forbidden'); - return; - } + + $submitCapabilities = $submitDecision->attribute('capabilities', []); + if (is_array($submitCapabilities)) { + $canUpdateTenant = (bool) ($submitCapabilities['can_update_tenant'] ?? $canUpdateTenant); + $canManageCustomFields = (bool) ($submitCapabilities['can_manage_custom_fields'] ?? $canManageCustomFields); + $canManageSso = (bool) ($submitCapabilities['can_manage_sso'] ?? $canManageSso); } + if (!$canUpdateTenant) { + Router::redirect('error/forbidden'); + return; + } + $result = directoryServicesFactory()->createTenantService()->updateFromAdmin($tenantId, $_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; diff --git a/pages/admin/tenants/favicon($id).php b/pages/admin/tenants/favicon($id).php index c528e5c..b11533a 100644 --- a/pages/admin/tenants/favicon($id).php +++ b/pages/admin/tenants/favicon($id).php @@ -1,13 +1,14 @@ createAuthorizationService(); $tenantFaviconService = (new TenantServicesFactory())->createTenantFaviconService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -22,6 +23,18 @@ if (!$tenantFaviconService->isValidUuid($uuid)) { return; } +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +$tenantId = (int) ($tenant['id'] ?? 0); +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $result = $tenantFaviconService->saveUpload($uuid, $_FILES['favicon'] ?? []); if (!($result['ok'] ?? false)) { $error = $result['error'] ?? t('Upload failed'); diff --git a/pages/admin/tenants/favicon-delete($id).php b/pages/admin/tenants/favicon-delete($id).php index 34e50f0..da89e43 100644 --- a/pages/admin/tenants/favicon-delete($id).php +++ b/pages/admin/tenants/favicon-delete($id).php @@ -1,13 +1,14 @@ createAuthorizationService(); $tenantFaviconService = (new TenantServicesFactory())->createTenantFaviconService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -22,6 +23,18 @@ if (!$tenantFaviconService->isValidUuid($uuid)) { return; } +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +$tenantId = (int) ($tenant['id'] ?? 0); +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE, [ + 'actor_user_id' => $currentUserId, + 'target_tenant_id' => $tenantId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $tenantFaviconService->delete($uuid); Flash::success('Favicon removed', "admin/tenants/edit/{$uuid}", 'tenant_favicon_removed'); Router::redirect("admin/tenants/edit/{$uuid}"); diff --git a/pages/admin/tenants/index().php b/pages/admin/tenants/index().php index 048469e..cd554fc 100644 --- a/pages/admin/tenants/index().php +++ b/pages/admin/tenants/index().php @@ -1,17 +1,23 @@ 0) { - permissionGateway()->getUserPermissions($currentUserId); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_VIEW, [ + 'actor_user_id' => $currentUserId, +]); +if (!$decision->isAllowed()) { + Guard::deny(); } +$capabilities = $decision->attribute('capabilities', []); +$canCreateTenants = is_array($capabilities) ? (bool) ($capabilities['can_create_tenant'] ?? false) : false; Buffer::set('title', t('Tenants')); Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); diff --git a/pages/admin/tenants/index(default).phtml b/pages/admin/tenants/index(default).phtml index db76f06..66688f2 100644 --- a/pages/admin/tenants/index(default).phtml +++ b/pages/admin/tenants/index(default).phtml @@ -1,7 +1,7 @@ createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); // Support both route param and POSTed uuid (POST avoids exposing uuid in URL/history). @@ -26,8 +27,11 @@ if (!$user) { $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $userId = (int) ($user['id'] ?? 0); -// Enforce tenant scope on the target user before rendering any sensitive document. -if ($userId <= 0 || !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/users/access-pdf-bulk().php b/pages/admin/users/access-pdf-bulk().php index 8fbb3cb..88bd06d 100644 --- a/pages/admin/users/access-pdf-bulk().php +++ b/pages/admin/users/access-pdf-bulk().php @@ -1,7 +1,8 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK, [ + 'actor_user_id' => (int) ($_SESSION['user']['id'] ?? 0), +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} $userAccountService = (new UserServicesFactory())->createUserAccountService(); if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') { diff --git a/pages/admin/users/activate($id).php b/pages/admin/users/activate($id).php index 1ae01c0..571d939 100644 --- a/pages/admin/users/activate($id).php +++ b/pages/admin/users/activate($id).php @@ -2,10 +2,13 @@ use MintyPHP\Http\Request; use MintyPHP\Router; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Guard; Guard::requireLogin(); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -17,13 +20,17 @@ $uuid = trim((string) ($id ?? '')); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_ACTIVATE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { if (Request::wantsJson()) { - http_response_code(403); - Router::json(['error' => 'permission_denied']); + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); return; } - Router::redirect('error/forbidden'); + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); return; } $result = $userAccountService->setActiveByUuid($uuid, true, $currentUserId); diff --git a/pages/admin/users/api-token-create($id).php b/pages/admin/users/api-token-create($id).php index 772ebb7..e6fc860 100644 --- a/pages/admin/users/api-token-create($id).php +++ b/pages/admin/users/api-token-create($id).php @@ -1,14 +1,15 @@ createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); $apiTokenService = (new AuthServicesFactory())->createApiTokenService(); @@ -20,6 +21,15 @@ if (!$user) { $userId = (int) ($user['id'] ?? 0); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $name = trim((string) ($_POST['token_name'] ?? '')); if ($name === '') { diff --git a/pages/admin/users/api-token-revoke($id).php b/pages/admin/users/api-token-revoke($id).php index c51dcbe..dfdbe98 100644 --- a/pages/admin/users/api-token-revoke($id).php +++ b/pages/admin/users/api-token-revoke($id).php @@ -1,14 +1,15 @@ createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); $apiTokenService = (new AuthServicesFactory())->createApiTokenService(); @@ -18,6 +19,17 @@ if (!$user) { Router::redirect('admin/users'); } +$userId = (int) ($user['id'] ?? 0); +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { + Router::redirect('error/forbidden'); + return; +} + $tokenId = (int) ($_POST['token_id'] ?? 0); if ($tokenId > 0) { diff --git a/pages/admin/users/avatar($id).php b/pages/admin/users/avatar($id).php index 303cb88..b67b7ec 100644 --- a/pages/admin/users/avatar($id).php +++ b/pages/admin/users/avatar($id).php @@ -1,11 +1,14 @@ createAuthorizationService(); $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAvatarService = $userServicesFactory->createUserAvatarService(); @@ -25,7 +28,11 @@ if (!$userAvatarService->isValidUuid($uuid)) { $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_UPLOAD, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/users/avatar-delete($id).php b/pages/admin/users/avatar-delete($id).php index b2b974b..7346423 100644 --- a/pages/admin/users/avatar-delete($id).php +++ b/pages/admin/users/avatar-delete($id).php @@ -1,11 +1,14 @@ createAuthorizationService(); $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAvatarService = $userServicesFactory->createUserAvatarService(); @@ -25,7 +28,11 @@ if (!$userAvatarService->isValidUuid($uuid)) { $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_DELETE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/users/avatar-file().php b/pages/admin/users/avatar-file().php index 6a942ec..d1e33e1 100644 --- a/pages/admin/users/avatar-file().php +++ b/pages/admin/users/avatar-file().php @@ -1,11 +1,14 @@ createAuthorizationService(); $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAvatarService = $userServicesFactory->createUserAvatarService(); @@ -20,7 +23,11 @@ if (!$userAvatarService->isValidUuid($uuid)) { $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_AVATAR_VIEW, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { http_response_code(403); return; } diff --git a/pages/admin/users/bulk($action).php b/pages/admin/users/bulk($action).php index d57c2bb..1ebb309 100644 --- a/pages/admin/users/bulk($action).php +++ b/pages/admin/users/bulk($action).php @@ -1,7 +1,8 @@ false, 'error' => 'invalid_action']); } -if ($action === 'delete') { - Guard::requirePermissionOrForbidden(PermissionService::USERS_DELETE); -} -if ($action === 'send-access') { - Guard::requirePermissionOrForbidden(PermissionService::USERS_UPDATE); -} $raw = $_POST['uuids'] ?? ''; $uuids = []; @@ -34,6 +29,16 @@ if (!$uuids) { } $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_BULK, [ + 'actor_user_id' => $currentUserId, + 'bulk_action' => $action, +]); +if (!$decision->isAllowed()) { + http_response_code($decision->status()); + Router::json(['ok' => false, 'error' => $decision->error()]); +} + $userAccountService = (new UserServicesFactory())->createUserAccountService(); $mailService = (new MailServicesFactory())->createMailService(); diff --git a/pages/admin/users/create().php b/pages/admin/users/create().php index 5597993..6cf3360 100644 --- a/pages/admin/users/create().php +++ b/pages/admin/users/create().php @@ -2,15 +2,31 @@ use MintyPHP\Buffer; use MintyPHP\I18n; +use MintyPHP\Http\Request; use MintyPHP\Router; +use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; -Guard::requirePermissionOrForbidden(PermissionService::USERS_CREATE); +Guard::requireLogin(); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_CREATE, [ + 'actor_user_id' => (int) ($_SESSION['user']['id'] ?? 0), +]); +if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); + return; +} $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAssignmentService = $userServicesFactory->createUserAssignmentService(); diff --git a/pages/admin/users/data().php b/pages/admin/users/data().php index 348bc2a..2e9bb3c 100644 --- a/pages/admin/users/data().php +++ b/pages/admin/users/data().php @@ -1,16 +1,31 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, [ + 'actor_user_id' => (int) ($_SESSION['user']['id'] ?? 0), +]); +if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); + return; +} + $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAvatarService = $userServicesFactory->createUserAvatarService(); -if (!isset($_SESSION['user'])) { - http_response_code(401); - Router::json(['data' => [], 'total' => 0]); -} - $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $limit = (int) ($_GET['limit'] ?? 10); diff --git a/pages/admin/users/deactivate($id).php b/pages/admin/users/deactivate($id).php index 294af67..9c1634a 100644 --- a/pages/admin/users/deactivate($id).php +++ b/pages/admin/users/deactivate($id).php @@ -2,11 +2,14 @@ use MintyPHP\Http\Request; use MintyPHP\Router; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -18,13 +21,17 @@ $uuid = trim((string) ($id ?? '')); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DEACTIVATE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { if (Request::wantsJson()) { - http_response_code(403); - Router::json(['error' => 'permission_denied']); + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); return; } - Router::redirect('error/forbidden'); + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); return; } $result = $userAccountService->setActiveByUuid($uuid, false, $currentUserId); diff --git a/pages/admin/users/delete($id).php b/pages/admin/users/delete($id).php index 1d4115c..8390656 100644 --- a/pages/admin/users/delete($id).php +++ b/pages/admin/users/delete($id).php @@ -2,13 +2,14 @@ use MintyPHP\Http\Request; use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; Guard::requireLogin(); -Guard::requirePermissionOrForbidden(PermissionService::USERS_DELETE); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { @@ -20,13 +21,17 @@ $uuid = trim((string) ($id ?? '')); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; $userId = (int) ($user['id'] ?? 0); -if ($userId > 0 && $currentUserId !== $userId && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_DELETE, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { if (Request::wantsJson()) { - http_response_code(403); - Router::json(['error' => 'permission_denied']); + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); return; } - Router::redirect('error/forbidden'); + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); return; } $result = $userAccountService->deleteByUuid($uuid, $currentUserId); diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 59b3939..f6f135f 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Auth\PasswordResetRepository; use MintyPHP\Repository\Auth\RememberTokenRepository; use MintyPHP\Router; use MintyPHP\Service\Access\AccessServicesFactory; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\Auth\AuthServicesFactory; use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\User\UserServicesFactory; @@ -24,6 +24,7 @@ $userDepartmentRepository = $userServicesFactory->createUserDepartmentRepository $userPasswordPolicyService = $userServicesFactory->createUserPasswordPolicyService(); $authServiceFactory = new AuthServicesFactory(); $accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); $rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); $authService = $authServiceFactory->createAuthService(); $apiTokenService = $authServiceFactory->createApiTokenService(); @@ -33,10 +34,6 @@ $passwordMinLength = $userPasswordPolicyService->minLength(); $passwordHints = $userPasswordPolicyService->hints(); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -if ($currentUserId > 0) { - permissionGateway()->getUserPermissions($currentUserId, true); -} - $uuid = trim((string) ($id ?? '')); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; if (!$user) { @@ -45,65 +42,91 @@ if (!$user) { } $userId = (int) ($user['id'] ?? 0); -$canViewUsers = permissionGateway()->userHas($currentUserId, PermissionService::USERS_VIEW); -$canUpdateUser = permissionGateway()->userHas($currentUserId, PermissionService::USERS_UPDATE); -$canUpdateSelf = permissionGateway()->userHas($currentUserId, PermissionService::USERS_SELF_UPDATE); -$canUpdateAssignments = permissionGateway()->userHas($currentUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS); -$isOwnAccount = $currentUserId === $userId; -$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser; -$canEditAssignments = $canEditUser && $canUpdateAssignments; -if (!$isOwnAccount && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$contextDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$contextDecision->isAllowed()) { Router::redirect('error/forbidden'); return; } -if (!$canViewUsers && !$canEditUser) { + +$capabilities = $contextDecision->attribute('capabilities', []); +if (!is_array($capabilities)) { + $capabilities = []; +} +$canViewUsers = (bool) ($capabilities['can_view_users'] ?? false); +$canViewPage = (bool) ($capabilities['can_view_page'] ?? false); +$canEditUser = (bool) ($capabilities['can_edit_user'] ?? false); +$canEditAssignments = (bool) ($capabilities['can_edit_assignments'] ?? false); +$canManageTenants = (bool) ($capabilities['can_manage_tenants'] ?? false); +$canEditCustomFieldValues = (bool) ($capabilities['can_edit_custom_field_values'] ?? false); +$canManageApiTokens = (bool) ($capabilities['can_manage_api_tokens'] ?? false); +$canViewAddressBook = (bool) ($capabilities['can_view_address_book'] ?? false); +$canViewUserMeta = (bool) ($capabilities['can_view_user_meta'] ?? false); +$canViewUserAudit = (bool) ($capabilities['can_view_user_audit'] ?? false); +$canAccessPdf = (bool) ($capabilities['can_access_pdf'] ?? false); +$canDeleteUser = (bool) ($capabilities['can_delete_user'] ?? false); +$canViewSecurityArtifacts = (bool) ($capabilities['can_view_security_artifacts'] ?? false); +$canViewPermissionsTable = (bool) ($capabilities['can_view_permissions_table'] ?? false); +$isOwnAccount = (bool) ($capabilities['is_own_account'] ?? false); +$allowedTenantIdsRaw = $capabilities['allowed_tenant_ids'] ?? null; +$allowedTenantIds = is_array($allowedTenantIdsRaw) + ? array_values(array_unique(array_filter(array_map('intval', $allowedTenantIdsRaw), static fn (int $tenantId): bool => $tenantId > 0))) + : null; + +if (!$canViewPage) { Router::redirect('error/forbidden'); return; } -$creatorId = (int) ($user['created_by'] ?? 0); -if ($creatorId > 0) { - $creator = $userAccountService->findById($creatorId); - if ($creator) { - $creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? '')); - $user['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? ''); - $user['created_by_uuid'] = $creator['uuid'] ?? null; - } -} -$modifierId = (int) ($user['modified_by'] ?? 0); -if ($modifierId > 0) { - $modifier = $userAccountService->findById($modifierId); - if ($modifier) { - $modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? '')); - $user['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? ''); - $user['modified_by_uuid'] = $modifier['uuid'] ?? null; - } -} -$activeChangedById = (int) ($user['active_changed_by'] ?? 0); -if ($activeChangedById > 0) { - $activeChanger = $userAccountService->findById($activeChangedById); - if ($activeChanger) { - $activeChangerName = trim(($activeChanger['first_name'] ?? '') . ' ' . ($activeChanger['last_name'] ?? '')); - $user['active_changed_by_label'] = $activeChangerName !== '' ? $activeChangerName : ($activeChanger['email'] ?? ''); - $user['active_changed_by_uuid'] = $activeChanger['uuid'] ?? null; + +if ($canViewUserMeta || $canViewUserAudit) { + $creatorId = (int) ($user['created_by'] ?? 0); + if ($creatorId > 0) { + $creator = $userAccountService->findById($creatorId); + if ($creator) { + $creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? '')); + $user['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? ''); + $user['created_by_uuid'] = $creator['uuid'] ?? null; + } + } + $modifierId = (int) ($user['modified_by'] ?? 0); + if ($modifierId > 0) { + $modifier = $userAccountService->findById($modifierId); + if ($modifier) { + $modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? '')); + $user['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? ''); + $user['modified_by_uuid'] = $modifier['uuid'] ?? null; + } + } + $activeChangedById = (int) ($user['active_changed_by'] ?? 0); + if ($activeChangedById > 0) { + $activeChanger = $userAccountService->findById($activeChangedById); + if ($activeChanger) { + $activeChangerName = trim(($activeChanger['first_name'] ?? '') . ' ' . ($activeChanger['last_name'] ?? '')); + $user['active_changed_by_label'] = $activeChangerName !== '' ? $activeChangerName : ($activeChanger['email'] ?? ''); + $user['active_changed_by_uuid'] = $activeChanger['uuid'] ?? null; + } } } + $errors = []; $form = $user; -// Users with tenants.update permission can see ALL tenants (to assign new tenants to users) -$canManageTenants = permissionGateway()->userHas($currentUserId, PermissionService::TENANTS_UPDATE); -$allowedTenantIds = $canManageTenants ? null : directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); +$scopeGateway = directoryServicesFactory()->createDirectoryScopeGateway(); +$strictTenantScope = $scopeGateway->isStrict(); $tenants = directoryServicesFactory()->createTenantService()->list(); -if ($allowedTenantIds) { +if (is_array($allowedTenantIds)) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); -} elseif (!$canManageTenants && directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { +} elseif (!$canManageTenants && $strictTenantScope) { $tenants = []; } + $selectedTenantIds = $userTenantRepository->listTenantIdsByUserId($userId); -if ($allowedTenantIds) { +if (is_array($allowedTenantIds)) { $selectedTenantIds = array_values(array_intersect($selectedTenantIds, $allowedTenantIds)); } $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); @@ -111,14 +134,15 @@ if (!$primaryTenantId && count($selectedTenantIds) === 1) { $primaryTenantId = (int) $selectedTenantIds[0]; $user['primary_tenant_id'] = $primaryTenantId; } + $roles = directoryServicesFactory()->createRoleService()->listActive(); $selectedRoleIds = $userRoleRepository->listRoleIdsByUserId($userId); -$permissionRows = $rolePermissionRepository->listPermissionsWithRolesByRoleIds($selectedRoleIds); +$permissionRows = $canViewPermissionsTable + ? $rolePermissionRepository->listPermissionsWithRolesByRoleIds($selectedRoleIds) + : []; $selectedDepartmentIds = $userDepartmentRepository->listDepartmentIdsByUserId($userId); $departmentOptionsByTenant = directoryServicesFactory()->createDepartmentService()->groupActiveByTenantIds($selectedTenantIds); -$canEditCustomFieldValues = $canEditUser - && (permissionGateway()->userHas($currentUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES) - || ($isOwnAccount && $canUpdateSelf)); + $customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds); $customFieldDefinitionIds = []; foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) { @@ -134,52 +158,65 @@ $customFieldPostedValues = [ 'custom_field_values' => [], 'custom_field_values_multi' => [], ]; + $passwordResets = []; -if ($canViewUsers || $canEditUser) { - $passwordResets = $passwordResetRepository->listByUserId($userId, 25); -} $rememberTokens = []; -if ($canViewUsers || $canEditUser) { - $rememberTokens = $rememberTokenRepository->listByUserId($userId, 25); -} $apiTokens = []; -$canManageApiTokens = $canEditUser - && permissionGateway()->userHas($currentUserId, PermissionService::API_TOKENS_MANAGE); -if ($canViewUsers || $canEditUser) { +if ($canViewSecurityArtifacts) { + $passwordResets = $passwordResetRepository->listByUserId($userId, 25); + $rememberTokens = $rememberTokenRepository->listByUserId($userId, 25); $apiTokens = $apiTokenService->listForUser($userId); } -$showApiTokens = !empty($apiTokens) || $canManageApiTokens; -// Keep initial $isOwnAccount/$canEditUser values for view permissions. +$canManageApiTokens = $canManageApiTokens && $canViewSecurityArtifacts; +$showApiTokens = $canViewSecurityArtifacts && (!empty($apiTokens) || $canManageApiTokens); if (isset($_POST['email'])) { - if (!$canEditUser) { + $submitDecision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_SUBMIT, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, + ]); + if (!$submitDecision->isAllowed()) { Router::redirect('error/forbidden'); return; } + + $submitCapabilities = $submitDecision->attribute('capabilities', []); + if (!is_array($submitCapabilities) || !isset($submitCapabilities['can_edit_user']) || !$submitCapabilities['can_edit_user']) { + Router::redirect('error/forbidden'); + return; + } + + $canEditUser = (bool) $submitCapabilities['can_edit_user']; + $canEditAssignments = (bool) ($submitCapabilities['can_edit_assignments'] ?? $canEditAssignments); + $canEditCustomFieldValues = (bool) ($submitCapabilities['can_edit_custom_field_values'] ?? $canEditCustomFieldValues); + $result = $userAccountService->updateFromAdmin($userId, $_POST, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $tenantIds = $userAssignmentService->normalizeIdInput($_POST['tenant_ids'] ?? []); $existingTenantIds = $userTenantRepository->listTenantIdsByUserId($userId); $tenantIdsForSync = $tenantIds; - if ($canEditAssignments && $allowedTenantIds) { + + if ($canEditAssignments && is_array($allowedTenantIds)) { $tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds)); - $tenantIdsForSync = directoryServicesFactory()->createDirectoryScopeGateway()->mergeTenantIdsPreservingOutOfScope( + $tenantIdsForSync = $scopeGateway->mergeTenantIdsPreservingOutOfScope( $tenantIds, $existingTenantIds, $allowedTenantIds ); - } elseif ($canEditAssignments && !$canManageTenants && directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { + } elseif ($canEditAssignments && !$canManageTenants && $strictTenantScope) { $tenantIds = []; $tenantIdsForSync = $existingTenantIds; } + if (!$canEditAssignments) { $tenantIds = $userTenantRepository->listTenantIdsByUserId($userId); - if ($allowedTenantIds) { + if (is_array($allowedTenantIds)) { $tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds)); } $tenantIdsForSync = $tenantIds; } + $selectedTenantIds = $tenantIds; $primaryTenantId = (int) ($_POST['primary_tenant_id'] ?? 0); $selectedRoleIds = $userAssignmentService->normalizeIdInput($_POST['role_ids'] ?? []); @@ -211,16 +248,16 @@ if (isset($_POST['email'])) { $userAssignmentService->syncDepartments($userId, $selectedDepartmentIds, false); $userAssignmentService->bumpAuthzVersion($userId); $db->commit(); - } catch (\Throwable $exception) { + } catch (\Throwable) { $db->rollback(); $errors[] = t('User assignments can not be updated'); } - // Update session if editing own account (tenant assignments changed) if ($currentUserId === $userId && !$errors) { $authService->loadTenantDataIntoSession($userId); } } + $customFieldSyncResult = UserCustomFieldValueService::syncForUser( $userId, $selectedTenantIds, diff --git a/pages/admin/users/edit(default).phtml b/pages/admin/users/edit(default).phtml index 676336a..98d4711 100644 --- a/pages/admin/users/edit(default).phtml +++ b/pages/admin/users/edit(default).phtml @@ -9,20 +9,22 @@ use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Session; -$values = $form ?? $user ?? []; +$values = $form; $avatarUuid = (string) ($values['uuid'] ?? ''); $userAvatarService = (new UserServicesFactory())->createUserAvatarService(); $hasAvatar = $avatarUuid !== '' && $userAvatarService->hasAvatar($avatarUuid); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $isOwnAccount = $currentUserId > 0 && $currentUserId === (int) ($values['id'] ?? 0); $titleText = $isOwnAccount ? t('My account') : t('Edit user'); -$canViewUsers = can('users.view'); -$canViewAddressBook = can('address_book.view'); -$canViewUserMeta = can('users.view_meta'); -$canViewUserAudit = can('users.view_audit'); -$canAccessPdf = can('users.access_pdf'); +$canViewUsers = !empty($canViewUsers); +$canViewAddressBook = !empty($canViewAddressBook); +$canViewUserMeta = !empty($canViewUserMeta); +$canViewUserAudit = !empty($canViewUserAudit); +$canAccessPdf = !empty($canAccessPdf); +$canViewSecurityArtifacts = !empty($canViewSecurityArtifacts); +$canViewPermissionsTable = !empty($canViewPermissionsTable); $canEditUser = !empty($canEditUser); -$canDeleteUser = can('users.delete') && !$isOwnAccount; +$canDeleteUser = !empty($canDeleteUser); $hideNavigation = $isOwnAccount && !$canViewUsers; $lastLoginProvider = strtolower((string) ($values['last_login_provider'] ?? ($user['last_login_provider'] ?? ''))); $lastLoginAt = (string) ($values['last_login_at'] ?? ($user['last_login_at'] ?? '')); @@ -142,11 +144,11 @@ if ($lastLoginAt !== '') { $isReadOnly = !$canEditUser; $canEditAssignments = $canEditAssignments ?? false; $permissionRows = $permissionRows ?? []; - $showPermissions = !empty($permissionRows) && can('permissions.view'); + $showPermissions = !empty($permissionRows) && $canViewPermissionsTable; $passwordResets = $passwordResets ?? []; - $showPasswordResets = !empty($passwordResets); + $showPasswordResets = $canViewSecurityArtifacts && !empty($passwordResets); $rememberTokens = $rememberTokens ?? []; - $showRememberTokens = !empty($rememberTokens); + $showRememberTokens = $canViewSecurityArtifacts && !empty($rememberTokens); $showDangerZone = $canDeleteUser; $dangerZoneDeleteFormId = $showDangerZone ? 'user-delete-form' : ''; $dangerZoneWarning = t('This will permanently delete this user.'); diff --git a/pages/admin/users/export().php b/pages/admin/users/export().php index 494c3de..df2eeb0 100644 --- a/pages/admin/users/export().php +++ b/pages/admin/users/export().php @@ -1,9 +1,26 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, [ + 'actor_user_id' => (int) ($_SESSION['user']['id'] ?? 0), +]); +if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); + return; +} $userAccountService = (new UserServicesFactory())->createUserAccountService(); $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); diff --git a/pages/admin/users/forget-tokens($id).php b/pages/admin/users/forget-tokens($id).php index bc84e59..19180a5 100644 --- a/pages/admin/users/forget-tokens($id).php +++ b/pages/admin/users/forget-tokens($id).php @@ -1,20 +1,18 @@ createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); $rememberMeService = (new AuthServicesFactory())->createRememberMeService(); -$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$canUpdateUser = permissionGateway()->userHas($currentUserId, PermissionService::USERS_UPDATE); -$canUpdateSelf = permissionGateway()->userHas($currentUserId, PermissionService::USERS_SELF_UPDATE); - $uuid = trim((string) ($id ?? '')); $user = $uuid !== '' ? $userAccountService->findByUuid($uuid) : null; if (!$user) { @@ -23,14 +21,12 @@ if (!$user) { } $userId = (int) ($user['id'] ?? 0); -$isOwnAccount = $currentUserId > 0 && $currentUserId === $userId; -$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser; - -if (!$canEditUser) { - Router::redirect('error/forbidden'); - return; -} -if (!$isOwnAccount && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_FORGET_TOKENS, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/admin/users/index().php b/pages/admin/users/index().php index 4c6c031..409c6e8 100644 --- a/pages/admin/users/index().php +++ b/pages/admin/users/index().php @@ -1,12 +1,27 @@ createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW, [ + 'actor_user_id' => (int) ($_SESSION['user']['id'] ?? 0), +]); +if (!$decision->isAllowed()) { + if (Request::wantsJson()) { + http_response_code($decision->status()); + Router::json(['error' => $decision->error()]); + return; + } + Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); + return; +} $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); if ($currentUserId > 0) { diff --git a/pages/admin/users/send-access($id).php b/pages/admin/users/send-access($id).php index 6832413..b0568ea 100644 --- a/pages/admin/users/send-access($id).php +++ b/pages/admin/users/send-access($id).php @@ -1,7 +1,8 @@ createAuthorizationService(); $userAccountService = (new UserServicesFactory())->createUserAccountService(); - -$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); -$canUpdateUser = permissionGateway()->userHas($currentUserId, PermissionService::USERS_UPDATE); -$canUpdateSelf = permissionGateway()->userHas($currentUserId, PermissionService::USERS_SELF_UPDATE); $mailService = (new MailServicesFactory())->createMailService(); $uuid = trim((string) ($id ?? '')); @@ -24,14 +22,12 @@ if (!$user) { } $userId = (int) ($user['id'] ?? 0); -$isOwnAccount = $currentUserId > 0 && $currentUserId === $userId; -$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser; - -if (!$canEditUser) { - Router::redirect('error/forbidden'); - return; -} -if (!$isOwnAccount && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('users', $userId, $currentUserId)) { +$currentUserId = (int) ($_SESSION['user']['id'] ?? 0); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_ADMIN_USERS_SEND_ACCESS, [ + 'actor_user_id' => $currentUserId, + 'target_user_id' => $userId, +]); +if (!$decision->isAllowed()) { Router::redirect('error/forbidden'); return; } diff --git a/pages/api/v1/auth/login().php b/pages/api/v1/auth/login().php index 607c662..d09f016 100644 --- a/pages/api/v1/auth/login().php +++ b/pages/api/v1/auth/login().php @@ -7,7 +7,7 @@ use MintyPHP\Service\Auth\AuthServicesFactory; use MintyPHP\Service\Security\SecurityServicesFactory; use MintyPHP\Service\User\UserServicesFactory; -ApiBootstrap::init(); +ApiBootstrap::init(false); ApiResponse::requireMethod('POST'); $input = ApiResponse::readJsonBody(); diff --git a/pages/api/v1/departments/index().php b/pages/api/v1/departments/index().php index 1e3096f..dba0d24 100644 --- a/pages/api/v1/departments/index().php +++ b/pages/api/v1/departments/index().php @@ -50,6 +50,20 @@ if ($method === 'POST') { ApiResponse::requirePermission(PermissionService::DEPARTMENTS_CREATE); $input = ApiResponse::readJsonBody(); + if (array_key_exists('tenant_id', $input)) { + ApiResponse::validationError(['tenant_id' => ['use_tenant_uuid']]); + } + if (array_key_exists('tenant_uuid', $input)) { + $tenantUuid = trim((string) ($input['tenant_uuid'] ?? '')); + if ($tenantUuid !== '') { + $tenant = directoryServicesFactory()->createTenantService()->findByUuid($tenantUuid); + if (!$tenant) { + ApiResponse::validationError(['tenant_uuid' => ['not_found']]); + } + $input['tenant_id'] = (int) ($tenant['id'] ?? 0); + } + unset($input['tenant_uuid']); + } $scopedTenantId = ApiAuth::scopedTenantId(); if ($scopedTenantId) { $postedTenantId = (int) ($input['tenant_id'] ?? 0); diff --git a/pages/api/v1/departments/show($id).php b/pages/api/v1/departments/show($id).php index 93e5565..87cfa94 100644 --- a/pages/api/v1/departments/show($id).php +++ b/pages/api/v1/departments/show($id).php @@ -24,14 +24,21 @@ if ($method === 'GET') { $departmentId = (int) ($department['id'] ?? 0); ApiAuth::requireResourceAccess('departments', $departmentId); + $tenantUuid = ''; + $tenantId = (int) ($department['tenant_id'] ?? 0); + if ($tenantId > 0) { + $tenant = directoryServicesFactory()->createTenantService()->findById($tenantId); + $tenantUuid = (string) ($tenant['uuid'] ?? ''); + } ApiResponse::success([ 'data' => [ 'uuid' => $department['uuid'] ?? '', 'description' => $department['description'] ?? '', 'code' => $department['code'] ?? '', + 'cost_center' => $department['cost_center'] ?? '', 'active' => (bool) ($department['active'] ?? 1), - 'tenant_id' => $department['tenant_id'] ?? null, + 'tenant_uuid' => $tenantUuid, 'created' => $department['created'] ?? '', 'modified' => $department['modified'] ?? '', ], @@ -50,6 +57,30 @@ if ($method === 'PUT' || $method === 'PATCH') { ApiAuth::requireResourceAccess('departments', $departmentId); $input = ApiResponse::readJsonBody(); + if (array_key_exists('tenant_id', $input)) { + ApiResponse::validationError(['tenant_id' => ['use_tenant_uuid']]); + } + if (array_key_exists('tenant_uuid', $input)) { + $tenantUuid = trim((string) ($input['tenant_uuid'] ?? '')); + if ($tenantUuid !== '') { + $tenant = directoryServicesFactory()->createTenantService()->findByUuid($tenantUuid); + if (!$tenant) { + ApiResponse::validationError(['tenant_uuid' => ['not_found']]); + } + $input['tenant_id'] = (int) ($tenant['id'] ?? 0); + } else { + $input['tenant_id'] = 0; + } + unset($input['tenant_uuid']); + } + $scopedTenantId = ApiAuth::scopedTenantId(); + if ($scopedTenantId) { + $postedTenantId = (int) ($input['tenant_id'] ?? 0); + if ($postedTenantId > 0 && $postedTenantId !== $scopedTenantId) { + ApiResponse::forbidden('tenant_scoped_token_forbidden'); + } + $input['tenant_id'] = $scopedTenantId; + } $result = directoryServicesFactory()->createDepartmentService()->updateFromAdmin($departmentId, $input, ApiAuth::userId()); ApiResponse::fromServiceResult($result); } diff --git a/pages/api/v1/me/avatar().php b/pages/api/v1/me/avatar().php new file mode 100644 index 0000000..8d8754d --- /dev/null +++ b/pages/api/v1/me/avatar().php @@ -0,0 +1,63 @@ +createUserAvatarService(); +$user = ApiAuth::user(); +$uuid = (string) ($user['uuid'] ?? ''); + +if ($method === 'GET') { + $size = isset($_GET['size']) ? (int) $_GET['size'] : null; + + $path = $userAvatarService->findAvatarPath($uuid, $size); + if (!$path || !is_file($path)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; + } + + $mime = $userAvatarService->detectMime($path); + header('Content-Type: ' . $mime); + header('X-Content-Type-Options: nosniff'); + header('Content-Security-Policy: sandbox'); + header('Cache-Control: private, max-age=300'); + header('Content-Length: ' . filesize($path)); + readfile($path); + return; +} + +if ($method === 'POST') { + if (!ApiAuth::hasPermission(PermissionService::USERS_SELF_UPDATE)) { + ApiResponse::forbidden(); + } + + $result = $userAvatarService->saveUpload($uuid, $_FILES['avatar'] ?? []); + if (!($result['ok'] ?? false)) { + $error = $result['error'] ?? 'upload_failed'; + ApiResponse::error($error, 422); + } + + ApiResponse::created(['data' => ['message' => 'avatar_updated']]); +} + +if ($method === 'DELETE') { + if (!ApiAuth::hasPermission(PermissionService::USERS_SELF_UPDATE)) { + ApiResponse::forbidden(); + } + + $userAvatarService->delete($uuid); + ApiResponse::noContent(); +} + +ApiResponse::methodNotAllowed(); diff --git a/pages/api/v1/me/index().php b/pages/api/v1/me/index().php index 1851ad4..d49f82c 100644 --- a/pages/api/v1/me/index().php +++ b/pages/api/v1/me/index().php @@ -8,30 +8,77 @@ use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\User\UserServicesFactory; ApiBootstrap::init(); -ApiResponse::requireMethod('GET'); -$userAssignmentService = (new UserServicesFactory())->createUserAssignmentService(); -$user = ApiAuth::user(); -$userId = ApiAuth::userId(); -$assignments = $userAssignmentService->buildAssignmentsForUser($userId); +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); +$userServicesFactory = new UserServicesFactory(); +$userAssignmentService = $userServicesFactory->createUserAssignmentService(); -$currentTenantId = (int) (ApiAuth::tenantId() ?? 0); -$currentTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $currentTenantId); +if ($method === 'GET') { + $user = ApiAuth::user(); + $userId = ApiAuth::userId(); -$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); -$primaryTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $primaryTenantId); + ApiResponse::success([ + 'data' => buildMeResponse($user, $userId, $userAssignmentService), + ]); +} -$publicAssignments = $userAssignmentService->mapAssignmentsToPublic($assignments); -$canViewPermissions = ApiAuth::hasPermission(PermissionService::PERMISSIONS_VIEW); -$publicPermissions = $canViewPermissions ? ApiAuth::permissions() : []; -$publicCustomFields = UserCustomFieldValueService::buildPublicValuesByTenant( - $userId, - $assignments['tenants'] ?? [], - ApiAuth::scopedTenantId() -); +if ($method === 'PUT' || $method === 'PATCH') { + if (!ApiAuth::hasPermission(PermissionService::USERS_SELF_UPDATE)) { + ApiResponse::forbidden(); + } -ApiResponse::success([ - 'data' => [ + $userId = ApiAuth::userId(); + $input = ApiResponse::readJsonBody(); + + $userAccountService = $userServicesFactory->createUserAccountService(); + $result = $userAccountService->updateSelfProfile($userId, $input); + if (!($result['ok'] ?? false)) { + $errors = $result['errors'] ?? []; + if (is_array($errors) && $errors !== []) { + ApiResponse::validationError(['profile' => array_values($errors)]); + } + ApiResponse::fromServiceResult($result); + } + + // Reload user after update + $user = $userAccountService->findById($userId); + if (!$user) { + ApiResponse::error('update_failed', 500); + } + + ApiResponse::success([ + 'data' => buildMeResponse($user, $userId, $userAssignmentService), + ]); +} + +ApiResponse::methodNotAllowed(); + +/** + * @param array $user + * @param int $userId + * @param \MintyPHP\Service\User\UserAssignmentService $userAssignmentService + * @return array + */ +function buildMeResponse(array $user, int $userId, $userAssignmentService): array +{ + $assignments = $userAssignmentService->buildAssignmentsForUser($userId); + + $currentTenantId = (int) (ApiAuth::tenantId() ?? 0); + $currentTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $currentTenantId); + + $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); + $primaryTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $primaryTenantId); + + $publicAssignments = $userAssignmentService->mapAssignmentsToPublic($assignments); + $canViewPermissions = ApiAuth::hasPermission(PermissionService::PERMISSIONS_VIEW); + $publicPermissions = $canViewPermissions ? ApiAuth::permissions() : []; + $publicCustomFields = UserCustomFieldValueService::buildPublicValuesByTenant( + $userId, + $assignments['tenants'] ?? [], + ApiAuth::scopedTenantId() + ); + + return [ 'uuid' => $user['uuid'] ?? '', 'first_name' => $user['first_name'] ?? '', 'last_name' => $user['last_name'] ?? '', @@ -41,11 +88,19 @@ ApiResponse::success([ 'phone' => $user['phone'] ?? '', 'mobile' => $user['mobile'] ?? '', 'locale' => $user['locale'] ?? '', + 'theme' => $user['theme'] ?? '', + 'profile_description' => $user['profile_description'] ?? '', + 'short_dial' => $user['short_dial'] ?? '', + 'address' => $user['address'] ?? '', + 'postal_code' => $user['postal_code'] ?? '', + 'city' => $user['city'] ?? '', + 'country' => $user['country'] ?? '', + 'region' => $user['region'] ?? '', 'current_tenant' => $currentTenant, 'primary_tenant' => $primaryTenant, 'can_view_permissions' => $canViewPermissions, 'permissions' => $publicPermissions, 'assignments' => $publicAssignments, 'custom_fields' => $publicCustomFields, - ], -]); + ]; +} diff --git a/pages/api/v1/me/password().php b/pages/api/v1/me/password().php new file mode 100644 index 0000000..b204081 --- /dev/null +++ b/pages/api/v1/me/password().php @@ -0,0 +1,58 @@ +createUserAccountService(); +$userPasswordService = $userServicesFactory->createUserPasswordService(); + +$userId = ApiAuth::userId(); +$user = $userAccountService->findById($userId); +if (!$user) { + ApiResponse::error('user_not_found', 404); +} + +if (!password_verify($currentPassword, (string) ($user['password'] ?? ''))) { + ApiResponse::validationError(['current_password' => ['invalid']]); +} + +$result = $userPasswordService->resetPassword($userId, $password, $password2); +if (!($result['ok'] ?? false)) { + $errors = $result['errors'] ?? []; + if ($errors) { + ApiResponse::validationError(['password' => $errors]); + } + ApiResponse::error('password_change_failed', 422); +} + +ApiResponse::noContent(); diff --git a/pages/api/v1/permissions/index().php b/pages/api/v1/permissions/index().php new file mode 100644 index 0000000..8db5529 --- /dev/null +++ b/pages/api/v1/permissions/index().php @@ -0,0 +1,46 @@ +createPermissionService(); + +$allowedOrders = ['key', 'description', 'active', 'created']; +$order = in_array($_GET['order'] ?? '', $allowedOrders, true) ? $_GET['order'] : 'key'; +$dir = in_array(strtolower($_GET['dir'] ?? ''), ['asc', 'desc'], true) ? strtolower($_GET['dir']) : 'asc'; +$limit = max(1, min(100, (int) ($_GET['limit'] ?? 25))); +$offset = max(0, (int) ($_GET['offset'] ?? 0)); + +$options = [ + 'limit' => $limit, + 'offset' => $offset, + 'search' => trim((string) ($_GET['search'] ?? '')), + 'active' => $_GET['active'] ?? null, + 'order' => $order, + 'dir' => $dir, +]; + +$result = $permissionService->listPaged($options); + +$rows = []; +foreach (($result['data'] ?? []) as $row) { + $rows[] = [ + 'key' => $row['key'] ?? '', + 'description' => $row['description'] ?? '', + 'active' => (bool) ($row['active'] ?? 1), + 'is_system' => (bool) ($row['is_system'] ?? 0), + ]; +} + +ApiResponse::success([ + 'data' => $rows, + 'total' => $result['total'] ?? 0, + 'limit' => $limit, + 'offset' => $offset, +]); diff --git a/pages/api/v1/roles/show($id).php b/pages/api/v1/roles/show($id).php index 511e67b..15a0135 100644 --- a/pages/api/v1/roles/show($id).php +++ b/pages/api/v1/roles/show($id).php @@ -3,6 +3,7 @@ use MintyPHP\Http\ApiAuth; use MintyPHP\Http\ApiBootstrap; use MintyPHP\Http\ApiResponse; +use MintyPHP\Service\Access\AccessServicesFactory; use MintyPHP\Service\Access\PermissionService; ApiBootstrap::init(); @@ -21,6 +22,14 @@ if ($method === 'GET') { if (!$role) { ApiResponse::notFound(); } + $roleId = (int) ($role['id'] ?? 0); + $permissionKeys = []; + if ($roleId > 0) { + $permissionKeys = (new AccessServicesFactory()) + ->createRolePermissionRepository() + ->listPermissionKeysByRoleIds([$roleId]); + sort($permissionKeys); + } ApiResponse::success([ 'data' => [ @@ -28,6 +37,7 @@ if ($method === 'GET') { 'description' => $role['description'] ?? '', 'code' => $role['code'] ?? '', 'active' => (bool) ($role['active'] ?? 1), + 'permission_keys' => $permissionKeys, 'created' => $role['created'] ?? '', 'modified' => $role['modified'] ?? '', ], diff --git a/pages/api/v1/settings/index().php b/pages/api/v1/settings/index().php new file mode 100644 index 0000000..1ac2cc6 --- /dev/null +++ b/pages/api/v1/settings/index().php @@ -0,0 +1,104 @@ +createSettingService(); + +if ($method === 'GET') { + ApiResponse::requirePermission(PermissionService::SETTINGS_VIEW); + + ApiResponse::success([ + 'data' => buildSettingsResponse($settingService), + ]); +} + +if ($method === 'PUT' || $method === 'PATCH') { + ApiResponse::requirePermission(PermissionService::SETTINGS_UPDATE); + + $input = ApiResponse::readJsonBody(); + $errors = []; + + $setters = [ + 'app_title' => static fn(?string $v) => $settingService->setAppTitle($v), + 'app_locale' => static fn(?string $v) => $settingService->setAppLocale($v), + 'app_theme' => static fn(?string $v) => $settingService->setAppTheme($v), + 'app_theme_user_allowed' => static fn($v) => $settingService->setUserThemeAllowed((bool) $v), + 'app_registration_enabled' => static fn($v) => $settingService->setRegistrationEnabled((bool) $v), + 'app_primary_color' => static fn(?string $v) => $settingService->setAppPrimaryColor($v), + 'api_token_default_ttl_days' => static fn($v) => $settingService->setApiTokenDefaultTtlDays($v !== null ? (int) $v : null), + 'api_token_max_ttl_days' => static fn($v) => $settingService->setApiTokenMaxTtlDays($v !== null ? (int) $v : null), + 'api_cors_allowed_origins' => static fn(?string $v) => $settingService->setApiCorsAllowedOrigins($v), + 'default_tenant_id' => static fn($v) => $settingService->setDefaultTenantId($v !== null ? (int) $v : null), + 'default_role_id' => static fn($v) => $settingService->setDefaultRoleId($v !== null ? (int) $v : null), + 'default_department_id' => static fn($v) => $settingService->setDefaultDepartmentId($v !== null ? (int) $v : null), + 'user_inactivity_deactivate_days' => static fn($v) => $settingService->setUserInactivityDeactivateDays($v !== null ? (int) $v : null), + 'user_inactivity_delete_days' => static fn($v) => $settingService->setUserInactivityDeleteDays($v !== null ? (int) $v : null), + 'microsoft_shared_client_id' => static fn(?string $v) => $settingService->setMicrosoftSharedClientId($v), + 'microsoft_authority' => static fn(?string $v) => $settingService->setMicrosoftAuthority($v), + 'smtp_host' => static fn(?string $v) => $settingService->setSmtpHost($v), + 'smtp_port' => static fn($v) => $settingService->setSmtpPort($v !== null ? (int) $v : null), + 'smtp_user' => static fn(?string $v) => $settingService->setSmtpUser($v), + 'smtp_password' => static fn(?string $v) => $settingService->setSmtpPassword($v), + 'smtp_secure' => static fn(?string $v) => $settingService->setSmtpSecure($v), + 'smtp_from' => static fn(?string $v) => $settingService->setSmtpFrom($v), + 'smtp_from_name' => static fn(?string $v) => $settingService->setSmtpFromName($v), + ]; + + foreach ($input as $key => $value) { + if (!isset($setters[$key])) { + continue; + } + $ok = $setters[$key]($value); + if (!$ok) { + $errors[$key] = ['invalid_value']; + } + } + + if ($errors) { + ApiResponse::validationError($errors); + } + + ApiResponse::success([ + 'data' => buildSettingsResponse($settingService), + ]); +} + +ApiResponse::methodNotAllowed(); + +/** + * @param \MintyPHP\Service\Settings\SettingService $s + * @return array + */ +function buildSettingsResponse(\MintyPHP\Service\Settings\SettingService $s): array +{ + return [ + 'app_title' => $s->getAppTitle(), + 'app_locale' => $s->getAppLocale(), + 'app_theme' => $s->getAppTheme(), + 'app_theme_user_allowed' => $s->isUserThemeAllowed(), + 'app_registration_enabled' => $s->isRegistrationEnabled(), + 'app_primary_color' => $s->getAppPrimaryColor(), + 'api_token_default_ttl_days' => $s->getApiTokenDefaultTtlDays(), + 'api_token_max_ttl_days' => $s->getApiTokenMaxTtlDays(), + 'api_cors_allowed_origins' => $s->getApiCorsAllowedOrigins(), + 'default_tenant_id' => $s->getDefaultTenantId(), + 'default_role_id' => $s->getDefaultRoleId(), + 'default_department_id' => $s->getDefaultDepartmentId(), + 'user_inactivity_deactivate_days' => $s->getUserInactivityDeactivateDays(), + 'user_inactivity_delete_days' => $s->getUserInactivityDeleteDays(), + 'microsoft_shared_client_id' => $s->getMicrosoftSharedClientId(), + 'microsoft_authority' => $s->getMicrosoftAuthority(), + 'smtp_host' => $s->getSmtpHost(), + 'smtp_port' => $s->getSmtpPort(), + 'smtp_user' => $s->getSmtpUser(), + 'smtp_secure' => $s->getSmtpSecure(), + 'smtp_from' => $s->getSmtpFrom(), + 'smtp_from_name' => $s->getSmtpFromName(), + ]; +} diff --git a/pages/api/v1/settings/public().php b/pages/api/v1/settings/public().php new file mode 100644 index 0000000..1b77ad1 --- /dev/null +++ b/pages/api/v1/settings/public().php @@ -0,0 +1,21 @@ +createSettingService(); + +header('Cache-Control: public, max-age=60'); + +ApiResponse::success([ + 'data' => [ + 'app_title' => $settingService->getAppTitle(), + 'app_primary_color' => $settingService->getAppPrimaryColor(), + 'default_theme' => $settingService->getAppTheme(), + 'registration_enabled' => $settingService->isRegistrationEnabled(), + ], +]); diff --git a/pages/api/v1/tenants/avatar($id).php b/pages/api/v1/tenants/avatar($id).php new file mode 100644 index 0000000..e695762 --- /dev/null +++ b/pages/api/v1/tenants/avatar($id).php @@ -0,0 +1,58 @@ + 'not_found']); + return; +} + +$tenantAvatarService = (new TenantServicesFactory())->createTenantAvatarService(); + +if (!$tenantAvatarService->isValidUuid($uuid)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; +} + +$tenant = directoryServicesFactory()->createTenantService()->findByUuid($uuid); +$tenantId = (int) ($tenant['id'] ?? 0); +if ($tenantId <= 0) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; +} + +ApiAuth::requireResourceAccess('tenants', $tenantId); + +$size = isset($_GET['size']) ? (int) $_GET['size'] : null; +$path = $tenantAvatarService->findAvatarPath($uuid, $size); +if (!$path || !is_file($path)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; +} + +$mime = $tenantAvatarService->detectMime($path); +header('Content-Type: ' . $mime); +header('X-Content-Type-Options: nosniff'); +header('Content-Security-Policy: sandbox'); +header('Cache-Control: private, max-age=300'); +header('Content-Length: ' . filesize($path)); +readfile($path); diff --git a/pages/api/v1/tenants/show($id).php b/pages/api/v1/tenants/show($id).php index 49503ad..22a91db 100644 --- a/pages/api/v1/tenants/show($id).php +++ b/pages/api/v1/tenants/show($id).php @@ -24,6 +24,12 @@ if ($method === 'GET') { $tenantId = (int) ($tenant['id'] ?? 0); ApiAuth::requireResourceAccess('tenants', $tenantId); + $allowUserThemeRaw = $tenant['allow_user_theme'] ?? null; + $allowUserThemeMode = ''; + if ($allowUserThemeRaw !== null && $allowUserThemeRaw !== '') { + $allowUserThemeMode = ((int) $allowUserThemeRaw) === 1 ? '1' : '0'; + } + $primaryColor = (string) ($tenant['primary_color'] ?? ''); ApiResponse::success([ 'data' => [ @@ -34,9 +40,22 @@ if ($method === 'GET') { 'postal_code' => $tenant['postal_code'] ?? '', 'city' => $tenant['city'] ?? '', 'country' => $tenant['country'] ?? '', + 'region' => $tenant['region'] ?? '', + 'vat_id' => $tenant['vat_id'] ?? '', + 'tax_number' => $tenant['tax_number'] ?? '', 'phone' => $tenant['phone'] ?? '', + 'fax' => $tenant['fax'] ?? '', 'email' => $tenant['email'] ?? '', + 'support_email' => $tenant['support_email'] ?? '', + 'support_phone' => $tenant['support_phone'] ?? '', + 'billing_email' => $tenant['billing_email'] ?? '', 'website' => $tenant['website'] ?? '', + 'privacy_url' => $tenant['privacy_url'] ?? '', + 'imprint_url' => $tenant['imprint_url'] ?? '', + 'primary_color' => $primaryColor, + 'primary_color_use_default' => $primaryColor === '', + 'default_theme' => $tenant['default_theme'] ?? '', + 'allow_user_theme_mode' => $allowUserThemeMode, 'created' => $tenant['created'] ?? '', 'modified' => $tenant['modified'] ?? '', ], diff --git a/pages/api/v1/users/avatar($id).php b/pages/api/v1/users/avatar($id).php new file mode 100644 index 0000000..69e90ca --- /dev/null +++ b/pages/api/v1/users/avatar($id).php @@ -0,0 +1,68 @@ + 'not_found']); + return; +} + +$userServicesFactory = new UserServicesFactory(); +$userAccountService = $userServicesFactory->createUserAccountService(); +$userAvatarService = $userServicesFactory->createUserAvatarService(); + +$user = $userAccountService->findByUuid($uuid); +$userId = (int) ($user['id'] ?? 0); + +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); +$decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET, [ + 'actor_user_id' => ApiAuth::userId(), + 'target_user_id' => $userId, + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), +]); + +if (!$decision->isAllowed()) { + $status = $decision->status() ?: 403; + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode(['error' => $decision->error() ?: 'forbidden']); + return; +} + +if (!$user) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; +} + +$size = isset($_GET['size']) ? (int) $_GET['size'] : null; +$path = $userAvatarService->findAvatarPath($uuid, $size); +if (!$path || !is_file($path)) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found']); + return; +} + +$mime = $userAvatarService->detectMime($path); +header('Content-Type: ' . $mime); +header('X-Content-Type-Options: nosniff'); +header('Content-Security-Policy: sandbox'); +header('Cache-Control: private, max-age=300'); +header('Content-Length: ' . filesize($path)); +readfile($path); diff --git a/pages/api/v1/users/index().php b/pages/api/v1/users/index().php index f1f0f77..b572712 100644 --- a/pages/api/v1/users/index().php +++ b/pages/api/v1/users/index().php @@ -3,17 +3,43 @@ use MintyPHP\Http\ApiAuth; use MintyPHP\Http\ApiBootstrap; use MintyPHP\Http\ApiResponse; -use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\AuthorizationDecision; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\User\UserServicesFactory; ApiBootstrap::init(); +$authorizationService = (new AccessServicesFactory())->createAuthorizationService(); $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); +$apiDeny = static function (AuthorizationDecision $decision): void { + if ($decision->isAllowed()) { + return; + } + + $status = $decision->status(); + $error = $decision->error() !== '' ? $decision->error() : 'forbidden'; + + if ($status === 401) { + ApiResponse::unauthorized(); + } + if ($status === 404) { + ApiResponse::notFound($error); + } + if ($status === 403) { + ApiResponse::forbidden($error); + } + + ApiResponse::error($error, $status > 0 ? $status : 403); +}; $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); if ($method === 'GET') { - ApiResponse::requirePermission(PermissionService::USERS_VIEW); + $decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_GET, [ + 'actor_user_id' => ApiAuth::userId(), + ]); + $apiDeny($decision); $limit = max(1, min(100, (int) ($_GET['limit'] ?? 25))); $offset = max(0, (int) ($_GET['offset'] ?? 0)); @@ -72,33 +98,142 @@ if ($method === 'GET') { } if ($method === 'POST') { - ApiResponse::requirePermission(PermissionService::USERS_CREATE); - $input = ApiResponse::readJsonBody(); - $scopedTenantId = ApiAuth::scopedTenantId(); - if ($scopedTenantId) { - $tenantIdsRaw = $input['tenant_ids'] ?? []; - if (!is_array($tenantIdsRaw)) { - $tenantIdsRaw = [$tenantIdsRaw]; - } - $tenantIds = array_values(array_unique(array_filter(array_map( - static fn ($value): int => (int) $value, - $tenantIdsRaw - ), static fn (int $value): bool => $value > 0))); - if ($tenantIds && array_diff($tenantIds, [$scopedTenantId])) { - ApiResponse::forbidden('tenant_scoped_token_forbidden'); - } - - $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); - if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { - ApiResponse::forbidden('tenant_scoped_token_forbidden'); - } - - $input['tenant_ids'] = [$scopedTenantId]; - $input['primary_tenant_id'] = $scopedTenantId; + $normalizeResult = normalizeUserWriteInputToInternalIds($input); + if ($normalizeResult['ok'] !== true) { + $errors = $normalizeResult['errors'] ?? []; + ApiResponse::validationError($errors ?: ['input' => ['invalid_value']]); } + $input = $normalizeResult['input'] ?? $input; + + $decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_INDEX_POST, [ + 'actor_user_id' => ApiAuth::userId(), + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), + 'input' => $input, + ]); + $apiDeny($decision); + $authorizedInput = $decision->attribute('input', null); + if (is_array($authorizedInput)) { + $input = $authorizedInput; + } + $result = $userAccountService->createFromAdmin($input, ApiAuth::userId()); ApiResponse::fromServiceResult($result, 201); } ApiResponse::methodNotAllowed(); + +/** + * Normalize public UUID-based assignment payload to internal IDs used by services. + * + * @param array $input + * @return array{ok: bool, input?: array, errors?: array>} + */ +function normalizeUserWriteInputToInternalIds(array $input): array +{ + $errors = []; + foreach ([ + 'tenant_ids' => 'use_tenant_uuids', + 'primary_tenant_id' => 'use_primary_tenant_uuid', + 'role_ids' => 'use_role_uuids', + 'department_ids' => 'use_department_uuids', + ] as $legacyKey => $errorCode) { + if (array_key_exists($legacyKey, $input)) { + $errors[$legacyKey] = [$errorCode]; + } + } + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + $tenantService = directoryServicesFactory()->createTenantService(); + $roleService = directoryServicesFactory()->createRoleService(); + $departmentService = directoryServicesFactory()->createDepartmentService(); + + $normalizeUuidList = static function (mixed $raw): array { + $values = is_array($raw) ? $raw : [$raw]; + $uuids = []; + foreach ($values as $value) { + $uuid = trim((string) $value); + if ($uuid !== '') { + $uuids[] = $uuid; + } + } + return array_values(array_unique($uuids)); + }; + + if (array_key_exists('tenant_uuids', $input)) { + $tenantUuids = $normalizeUuidList($input['tenant_uuids']); + $tenantIds = []; + foreach ($tenantUuids as $tenantUuid) { + $tenant = $tenantService->findByUuid($tenantUuid); + if (!$tenant) { + $errors['tenant_uuids'][] = 'not_found'; + continue; + } + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId > 0) { + $tenantIds[] = $tenantId; + } + } + $input['tenant_ids'] = array_values(array_unique($tenantIds)); + unset($input['tenant_uuids']); + } + + if (array_key_exists('primary_tenant_uuid', $input)) { + $primaryTenantUuid = trim((string) ($input['primary_tenant_uuid'] ?? '')); + if ($primaryTenantUuid === '') { + $input['primary_tenant_id'] = 0; + } else { + $primaryTenant = $tenantService->findByUuid($primaryTenantUuid); + if (!$primaryTenant) { + $errors['primary_tenant_uuid'][] = 'not_found'; + } else { + $input['primary_tenant_id'] = (int) ($primaryTenant['id'] ?? 0); + } + } + unset($input['primary_tenant_uuid']); + } + + if (array_key_exists('role_uuids', $input)) { + $roleUuids = $normalizeUuidList($input['role_uuids']); + $roleIds = []; + foreach ($roleUuids as $roleUuid) { + $role = $roleService->findByUuid($roleUuid); + if (!$role) { + $errors['role_uuids'][] = 'not_found'; + continue; + } + $roleId = (int) ($role['id'] ?? 0); + if ($roleId > 0) { + $roleIds[] = $roleId; + } + } + $input['role_ids'] = array_values(array_unique($roleIds)); + unset($input['role_uuids']); + } + + if (array_key_exists('department_uuids', $input)) { + $departmentUuids = $normalizeUuidList($input['department_uuids']); + $departmentIds = []; + foreach ($departmentUuids as $departmentUuid) { + $department = $departmentService->findByUuid($departmentUuid); + if (!$department) { + $errors['department_uuids'][] = 'not_found'; + continue; + } + $departmentId = (int) ($department['id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[] = $departmentId; + } + } + $input['department_ids'] = array_values(array_unique($departmentIds)); + unset($input['department_uuids']); + } + + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + return ['ok' => true, 'input' => $input]; +} diff --git a/pages/api/v1/users/show($id).php b/pages/api/v1/users/show($id).php index 4d6bf32..0938791 100644 --- a/pages/api/v1/users/show($id).php +++ b/pages/api/v1/users/show($id).php @@ -4,16 +4,40 @@ use MintyPHP\Http\ApiAuth; use MintyPHP\Http\ApiBootstrap; use MintyPHP\Http\ApiResponse; use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\AuthorizationDecision; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\UserAuthorizationPolicy; use MintyPHP\Service\CustomField\UserCustomFieldValueService; use MintyPHP\Service\User\UserServicesFactory; ApiBootstrap::init(); +$accessServicesFactory = new AccessServicesFactory(); +$authorizationService = $accessServicesFactory->createAuthorizationService(); $userServicesFactory = new UserServicesFactory(); $userAccountService = $userServicesFactory->createUserAccountService(); $userAssignmentService = $userServicesFactory->createUserAssignmentService(); $userRoleRepository = $userServicesFactory->createUserRoleRepository(); -$rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository(); +$rolePermissionRepository = $accessServicesFactory->createRolePermissionRepository(); +$apiDeny = static function (AuthorizationDecision $decision): void { + if ($decision->isAllowed()) { + return; + } + + $status = $decision->status(); + $error = $decision->error() !== '' ? $decision->error() : 'forbidden'; + + if ($status === 401) { + ApiResponse::unauthorized(); + } + if ($status === 404) { + ApiResponse::notFound($error); + } + if ($status === 403) { + ApiResponse::forbidden($error); + } + + ApiResponse::error($error, $status > 0 ? $status : 403); +}; $uuid = trim((string) ($id ?? '')); if ($uuid === '') { @@ -23,16 +47,21 @@ if ($uuid === '') { $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); if ($method === 'GET') { - ApiResponse::requirePermission(PermissionService::USERS_VIEW); - $user = $userAccountService->findByUuid($uuid); + $userId = (int) ($user['id'] ?? 0); + $decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_GET, [ + 'actor_user_id' => ApiAuth::userId(), + 'target_user_id' => $userId, + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), + ]); + $apiDeny($decision); if (!$user) { ApiResponse::notFound(); } - $userId = (int) ($user['id'] ?? 0); - ApiAuth::requireResourceAccess('users', $userId); $assignments = $userAssignmentService->buildAssignmentsForUser($userId); + $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); + $currentTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $currentTenantId); $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); $primaryTenant = $userAssignmentService->findTenantSummaryInAssignments($assignments, $primaryTenantId); $publicAssignments = $userAssignmentService->mapAssignmentsToPublic($assignments); @@ -54,6 +83,7 @@ if ($method === 'GET') { 'last_name' => $user['last_name'] ?? '', 'display_name' => $user['display_name'] ?? '', 'email' => $user['email'] ?? '', + 'profile_description' => $user['profile_description'] ?? '', 'job_title' => $user['job_title'] ?? '', 'phone' => $user['phone'] ?? '', 'mobile' => $user['mobile'] ?? '', @@ -62,9 +92,17 @@ if ($method === 'GET') { 'postal_code' => $user['postal_code'] ?? '', 'city' => $user['city'] ?? '', 'country' => $user['country'] ?? '', + 'region' => $user['region'] ?? '', + 'hire_date' => $user['hire_date'] ?? '', + 'locale' => $user['locale'] ?? '', + 'theme' => $user['theme'] ?? '', + 'email_verified_at' => $user['email_verified_at'] ?? '', + 'last_login_at' => $user['last_login_at'] ?? '', + 'last_login_provider' => $user['last_login_provider'] ?? '', 'active' => (bool) ($user['active'] ?? 1), 'created' => $user['created'] ?? '', 'modified' => $user['modified'] ?? '', + 'current_tenant' => $currentTenant, 'primary_tenant' => $primaryTenant, 'permissions' => $publicPermissions, 'assignments' => $publicAssignments, @@ -74,56 +112,166 @@ if ($method === 'GET') { } if ($method === 'PUT' || $method === 'PATCH') { - ApiResponse::requirePermission(PermissionService::USERS_UPDATE); - $user = $userAccountService->findByUuid($uuid); + $userId = (int) ($user['id'] ?? 0); + + $input = ApiResponse::readJsonBody(); + $normalizeResult = normalizeUserWriteInputToInternalIds($input); + if ($normalizeResult['ok'] !== true) { + $errors = $normalizeResult['errors'] ?? []; + ApiResponse::validationError($errors ?: ['input' => ['invalid_value']]); + } + $input = $normalizeResult['input'] ?? $input; + + $decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_UPDATE, [ + 'actor_user_id' => ApiAuth::userId(), + 'target_user_id' => $userId, + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), + 'input' => $input, + ]); + $apiDeny($decision); if (!$user) { ApiResponse::notFound(); } - $userId = (int) ($user['id'] ?? 0); - ApiAuth::requireResourceAccess('users', $userId); - - $input = ApiResponse::readJsonBody(); - $scopedTenantId = ApiAuth::scopedTenantId(); - if ($scopedTenantId) { - if (array_key_exists('tenant_ids', $input)) { - $tenantIdsRaw = $input['tenant_ids']; - if (!is_array($tenantIdsRaw)) { - $tenantIdsRaw = [$tenantIdsRaw]; - } - $tenantIds = array_values(array_unique(array_filter(array_map( - static fn ($value): int => (int) $value, - $tenantIdsRaw - ), static fn (int $value): bool => $value > 0))); - if ($tenantIds && array_diff($tenantIds, [$scopedTenantId])) { - ApiResponse::forbidden('tenant_scoped_token_forbidden'); - } - } - if (array_key_exists('primary_tenant_id', $input)) { - $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); - if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) { - ApiResponse::forbidden('tenant_scoped_token_forbidden'); - } - } + $authorizedInput = $decision->attribute('input', null); + if (is_array($authorizedInput)) { + $input = $authorizedInput; } $result = $userAccountService->updateFromAdmin($userId, $input, ApiAuth::userId()); ApiResponse::fromServiceResult($result); } if ($method === 'DELETE') { - ApiResponse::requirePermission(PermissionService::USERS_DELETE); - $user = $userAccountService->findByUuid($uuid); + $userId = (int) ($user['id'] ?? 0); + $decision = $authorizationService->authorize(UserAuthorizationPolicy::ABILITY_API_USERS_SHOW_DELETE, [ + 'actor_user_id' => ApiAuth::userId(), + 'target_user_id' => $userId, + 'scoped_tenant_id' => ApiAuth::scopedTenantId(), + ]); + $apiDeny($decision); if (!$user) { ApiResponse::notFound(); } - $userId = (int) ($user['id'] ?? 0); - ApiAuth::requireResourceAccess('users', $userId); - $result = $userAccountService->deleteByUuid($uuid, ApiAuth::userId()); ApiResponse::fromServiceResult($result); } ApiResponse::methodNotAllowed(); + +/** + * Normalize public UUID-based assignment payload to internal IDs used by services. + * + * @param array $input + * @return array{ok: bool, input?: array, errors?: array>} + */ +function normalizeUserWriteInputToInternalIds(array $input): array +{ + $errors = []; + foreach ([ + 'tenant_ids' => 'use_tenant_uuids', + 'primary_tenant_id' => 'use_primary_tenant_uuid', + 'role_ids' => 'use_role_uuids', + 'department_ids' => 'use_department_uuids', + ] as $legacyKey => $errorCode) { + if (array_key_exists($legacyKey, $input)) { + $errors[$legacyKey] = [$errorCode]; + } + } + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + $tenantService = directoryServicesFactory()->createTenantService(); + $roleService = directoryServicesFactory()->createRoleService(); + $departmentService = directoryServicesFactory()->createDepartmentService(); + + $normalizeUuidList = static function (mixed $raw): array { + $values = is_array($raw) ? $raw : [$raw]; + $uuids = []; + foreach ($values as $value) { + $uuid = trim((string) $value); + if ($uuid !== '') { + $uuids[] = $uuid; + } + } + return array_values(array_unique($uuids)); + }; + + if (array_key_exists('tenant_uuids', $input)) { + $tenantUuids = $normalizeUuidList($input['tenant_uuids']); + $tenantIds = []; + foreach ($tenantUuids as $tenantUuid) { + $tenant = $tenantService->findByUuid($tenantUuid); + if (!$tenant) { + $errors['tenant_uuids'][] = 'not_found'; + continue; + } + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId > 0) { + $tenantIds[] = $tenantId; + } + } + $input['tenant_ids'] = array_values(array_unique($tenantIds)); + unset($input['tenant_uuids']); + } + + if (array_key_exists('primary_tenant_uuid', $input)) { + $primaryTenantUuid = trim((string) ($input['primary_tenant_uuid'] ?? '')); + if ($primaryTenantUuid === '') { + $input['primary_tenant_id'] = 0; + } else { + $primaryTenant = $tenantService->findByUuid($primaryTenantUuid); + if (!$primaryTenant) { + $errors['primary_tenant_uuid'][] = 'not_found'; + } else { + $input['primary_tenant_id'] = (int) ($primaryTenant['id'] ?? 0); + } + } + unset($input['primary_tenant_uuid']); + } + + if (array_key_exists('role_uuids', $input)) { + $roleUuids = $normalizeUuidList($input['role_uuids']); + $roleIds = []; + foreach ($roleUuids as $roleUuid) { + $role = $roleService->findByUuid($roleUuid); + if (!$role) { + $errors['role_uuids'][] = 'not_found'; + continue; + } + $roleId = (int) ($role['id'] ?? 0); + if ($roleId > 0) { + $roleIds[] = $roleId; + } + } + $input['role_ids'] = array_values(array_unique($roleIds)); + unset($input['role_uuids']); + } + + if (array_key_exists('department_uuids', $input)) { + $departmentUuids = $normalizeUuidList($input['department_uuids']); + $departmentIds = []; + foreach ($departmentUuids as $departmentUuid) { + $department = $departmentService->findByUuid($departmentUuid); + if (!$department) { + $errors['department_uuids'][] = 'not_found'; + continue; + } + $departmentId = (int) ($department['id'] ?? 0); + if ($departmentId > 0) { + $departmentIds[] = $departmentId; + } + } + $input['department_ids'] = array_values(array_unique($departmentIds)); + unset($input['department_uuids']); + } + + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + return ['ok' => true, 'input' => $input]; +} diff --git a/templates/partials/app-main-aside.phtml b/templates/partials/app-main-aside.phtml index 7511d98..8d7a314 100644 --- a/templates/partials/app-main-aside.phtml +++ b/templates/partials/app-main-aside.phtml @@ -282,7 +282,7 @@ $csrfToken = $_SESSION[$csrfKey] ?? '';