weitere updates für instanzierbarkeit und sauberes testing

This commit is contained in:
2026-02-24 08:49:40 +01:00
parent 7b53faca37
commit 16759a2732
119 changed files with 6371 additions and 745 deletions

120
CLAUDE.md Normal file
View File

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

View File

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

View File

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

View File

@@ -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: `<ip>`
- Limit: `10` Fehlversuche pro `600` Sekunden
- Block: `300` Sekunden
2. Schritt `login_email_ip` (email + ip)
- Scope: `api.auth.login.email_ip`
- Key: `<lowercase-email>|<ip>`
- 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)

View File

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

View File

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

View File

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

View File

@@ -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 <selector:secret>`)
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 <selector:secret>`)
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

View File

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

View File

@@ -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_<definition_uuid>` (scalar/select/bool)
- `cfm_<definition_uuid>` (multiselect als CSV)
- `cfd_<definition_uuid>_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`

39
docs/lib-standards.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\Service\Access;
class AuthorizationDecision
{
public function __construct(
private readonly bool $allowed,
private readonly int $status = 200,
private readonly string $error = '',
private readonly array $attributes = []
) {
}
public static function allow(array $attributes = []): self
{
return new self(true, 200, '', $attributes);
}
public static function deny(int $status = 403, string $error = 'forbidden', array $attributes = []): self
{
return new self(false, $status, $error, $attributes);
}
public function isAllowed(): bool
{
return $this->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;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace MintyPHP\Service\Access;
interface AuthorizationPolicyInterface
{
public function supports(string $ability): bool;
public function authorize(string $ability, array $context = []): AuthorizationDecision;
}

View File

@@ -0,0 +1,41 @@
<?php
namespace MintyPHP\Service\Access;
use Throwable;
class AuthorizationService
{
/** @var array<int, AuthorizationPolicyInterface> */
private array $policies = [];
/**
* @param array<int, AuthorizationPolicyInterface> $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');
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace MintyPHP\Service\Access;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
class DepartmentAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_DEPARTMENTS_VIEW = 'admin.departments.view';
public const ABILITY_ADMIN_DEPARTMENTS_CREATE = 'admin.departments.create';
public const ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT = 'admin.departments.edit.context';
public const ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT = 'admin.departments.edit.submit';
public const ABILITY_ADMIN_DEPARTMENTS_DELETE = 'admin.departments.delete';
public function __construct(
private readonly PermissionGateway $permissionGateway,
private readonly DirectoryScopeGateway $scopeGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_DEPARTMENTS_VIEW,
self::ABILITY_ADMIN_DEPARTMENTS_CREATE,
self::ABILITY_ADMIN_DEPARTMENTS_EDIT_CONTEXT,
self::ABILITY_ADMIN_DEPARTMENTS_EDIT_SUBMIT,
self::ABILITY_ADMIN_DEPARTMENTS_DELETE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_DEPARTMENTS_VIEW => $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 : [];
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace MintyPHP\Service\Access;
class PermissionAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_PERMISSIONS_VIEW = 'admin.permissions.view';
public const ABILITY_ADMIN_PERMISSIONS_CREATE = 'admin.permissions.create';
public const ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT = 'admin.permissions.edit.context';
public const ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT = 'admin.permissions.edit.submit';
public const ABILITY_ADMIN_PERMISSIONS_DELETE = 'admin.permissions.delete';
public function __construct(
private readonly PermissionGateway $permissionGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_PERMISSIONS_VIEW,
self::ABILITY_ADMIN_PERMISSIONS_CREATE,
self::ABILITY_ADMIN_PERMISSIONS_EDIT_CONTEXT,
self::ABILITY_ADMIN_PERMISSIONS_EDIT_SUBMIT,
self::ABILITY_ADMIN_PERMISSIONS_DELETE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_PERMISSIONS_VIEW => $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 : [];
}
}

View File

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

View File

@@ -0,0 +1,127 @@
<?php
namespace MintyPHP\Service\Access;
class RoleAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_ROLES_VIEW = 'admin.roles.view';
public const ABILITY_ADMIN_ROLES_CREATE = 'admin.roles.create';
public const ABILITY_ADMIN_ROLES_EDIT_CONTEXT = 'admin.roles.edit.context';
public const ABILITY_ADMIN_ROLES_EDIT_SUBMIT = 'admin.roles.edit.submit';
public const ABILITY_ADMIN_ROLES_DELETE = 'admin.roles.delete';
public function __construct(
private readonly PermissionGateway $permissionGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_ROLES_VIEW,
self::ABILITY_ADMIN_ROLES_CREATE,
self::ABILITY_ADMIN_ROLES_EDIT_CONTEXT,
self::ABILITY_ADMIN_ROLES_EDIT_SUBMIT,
self::ABILITY_ADMIN_ROLES_DELETE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_ROLES_VIEW => $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 : [];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace MintyPHP\Service\Access;
class SettingsAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_SETTINGS_VIEW = 'admin.settings.view';
public const ABILITY_ADMIN_SETTINGS_UPDATE = 'admin.settings.update';
public const ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE = 'admin.settings.branding.update';
public const ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE = 'admin.settings.tokens.revoke';
public const ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN = 'admin.settings.user_lifecycle.run';
public function __construct(
private readonly PermissionGateway $permissionGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_SETTINGS_VIEW,
self::ABILITY_ADMIN_SETTINGS_UPDATE,
self::ABILITY_ADMIN_SETTINGS_BRANDING_UPDATE,
self::ABILITY_ADMIN_SETTINGS_TOKENS_REVOKE,
self::ABILITY_ADMIN_SETTINGS_USER_LIFECYCLE_RUN,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_SETTINGS_VIEW => $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);
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace MintyPHP\Service\Access;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
class TenantAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_TENANTS_VIEW = 'admin.tenants.view';
public const ABILITY_ADMIN_TENANTS_CREATE = 'admin.tenants.create';
public const ABILITY_ADMIN_TENANTS_EDIT_CONTEXT = 'admin.tenants.edit.context';
public const ABILITY_ADMIN_TENANTS_EDIT_SUBMIT = 'admin.tenants.edit.submit';
public const ABILITY_ADMIN_TENANTS_DELETE = 'admin.tenants.delete';
public const ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE = 'admin.tenants.custom_fields.manage';
public const ABILITY_ADMIN_TENANTS_AVATAR_VIEW = 'admin.tenants.avatar.view';
public const ABILITY_ADMIN_TENANTS_MEDIA_UPDATE = 'admin.tenants.media.update';
public function __construct(
private readonly PermissionGateway $permissionGateway,
private readonly DirectoryScopeGateway $scopeGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_TENANTS_VIEW,
self::ABILITY_ADMIN_TENANTS_CREATE,
self::ABILITY_ADMIN_TENANTS_EDIT_CONTEXT,
self::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT,
self::ABILITY_ADMIN_TENANTS_DELETE,
self::ABILITY_ADMIN_TENANTS_CUSTOM_FIELDS_MANAGE,
self::ABILITY_ADMIN_TENANTS_AVATAR_VIEW,
self::ABILITY_ADMIN_TENANTS_MEDIA_UPDATE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_TENANTS_VIEW => $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 : [];
}
}

View File

@@ -0,0 +1,455 @@
<?php
namespace MintyPHP\Service\Access;
use MintyPHP\Service\Directory\DirectoryScopeGateway;
class UserAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADMIN_USERS_VIEW = 'admin.users.view';
public const ABILITY_ADMIN_USERS_CREATE = 'admin.users.create';
public const ABILITY_ADMIN_USERS_EDIT_CONTEXT = 'admin.users.edit.context';
public const ABILITY_ADMIN_USERS_EDIT_SUBMIT = 'admin.users.edit.submit';
public const ABILITY_ADMIN_USERS_ACTIVATE = 'admin.users.activate';
public const ABILITY_ADMIN_USERS_DEACTIVATE = 'admin.users.deactivate';
public const ABILITY_ADMIN_USERS_DELETE = 'admin.users.delete';
public const ABILITY_ADMIN_USERS_BULK = 'admin.users.bulk';
public const ABILITY_ADMIN_USERS_ACCESS_PDF = 'admin.users.access_pdf';
public const ABILITY_ADMIN_USERS_ACCESS_PDF_BULK = 'admin.users.access_pdf_bulk';
public const ABILITY_ADMIN_USERS_AVATAR_UPLOAD = 'admin.users.avatar.upload';
public const ABILITY_ADMIN_USERS_AVATAR_DELETE = 'admin.users.avatar.delete';
public const ABILITY_ADMIN_USERS_AVATAR_VIEW = 'admin.users.avatar.view';
public const ABILITY_ADMIN_USERS_API_TOKENS_MANAGE = 'admin.users.api_tokens_manage';
public const ABILITY_ADMIN_USERS_SEND_ACCESS = 'admin.users.send_access';
public const ABILITY_ADMIN_USERS_FORGET_TOKENS = 'admin.users.forget_tokens';
public const ABILITY_API_USERS_INDEX_GET = 'api.users.index.get';
public const ABILITY_API_USERS_INDEX_POST = 'api.users.index.post';
public const ABILITY_API_USERS_SHOW_GET = 'api.users.show.get';
public const ABILITY_API_USERS_SHOW_UPDATE = 'api.users.show.update';
public const ABILITY_API_USERS_SHOW_DELETE = 'api.users.show.delete';
public function __construct(
private readonly PermissionGateway $permissionGateway,
private readonly DirectoryScopeGateway $scopeGateway
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADMIN_USERS_VIEW,
self::ABILITY_ADMIN_USERS_CREATE,
self::ABILITY_ADMIN_USERS_EDIT_CONTEXT,
self::ABILITY_ADMIN_USERS_EDIT_SUBMIT,
self::ABILITY_ADMIN_USERS_ACTIVATE,
self::ABILITY_ADMIN_USERS_DEACTIVATE,
self::ABILITY_ADMIN_USERS_DELETE,
self::ABILITY_ADMIN_USERS_BULK,
self::ABILITY_ADMIN_USERS_ACCESS_PDF,
self::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK,
self::ABILITY_ADMIN_USERS_AVATAR_UPLOAD,
self::ABILITY_ADMIN_USERS_AVATAR_DELETE,
self::ABILITY_ADMIN_USERS_AVATAR_VIEW,
self::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE,
self::ABILITY_ADMIN_USERS_SEND_ACCESS,
self::ABILITY_ADMIN_USERS_FORGET_TOKENS,
self::ABILITY_API_USERS_INDEX_GET,
self::ABILITY_API_USERS_INDEX_POST,
self::ABILITY_API_USERS_SHOW_GET,
self::ABILITY_API_USERS_SHOW_UPDATE,
self::ABILITY_API_USERS_SHOW_DELETE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
return match ($ability) {
self::ABILITY_ADMIN_USERS_VIEW => $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();
}
}

View File

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

View File

@@ -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<string>, form?: array<string, mixed>}
*/
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 = [];

View File

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

View File

@@ -1,16 +1,24 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$tenantIds = directoryServicesFactory()->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);

View File

@@ -1,22 +1,35 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_DELETE);
$authorizationService = (new AccessServicesFactory())->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) {

View File

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

View File

@@ -1,26 +1,33 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\DepartmentAuthorizationPolicy;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 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) {

View File

@@ -1,7 +1,7 @@
<?php
use MintyPHP\Router;
$canCreateDepartments = can('departments.create');
$canCreateDepartments = (bool) ($canCreateDepartments ?? false);
$activeTenant = trim((string) ($_GET['tenant'] ?? ''));
$activeStatus = trim((string) ($_GET['active'] ?? ''));
$showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,15 @@
* @var array<int, string> $errors
* @var array $form
* @var array $permission
* @var bool $canUpdatePermission
* @var bool $canDeletePermission
*/
use MintyPHP\Session;
$canUpdatePermission = (bool) ($canUpdatePermission ?? false);
$canDeletePermission = (bool) ($canDeletePermission ?? false);
?>
<div class="app-details-container">
<section>
@@ -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;
<?php endif; ?>
<?php
$values = $form ?? $permission ?? [];
$isReadOnly = !can('permissions.update');
$isReadOnly = !$canUpdatePermission;
$detailsOpenAll = true;
$isReadOnly = $isReadOnly ?? false;
$showDangerZone = can('permissions.delete') && empty($permission['is_system']);
$showDangerZone = $canDeletePermission;
$dangerZoneDeleteFormId = $showDangerZone ? 'permission-delete-form' : '';
$dangerZoneWarning = t('This will permanently delete this permission.');
$dangerZoneActionLabel = t('Delete permission');

View File

@@ -1,17 +1,24 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionAuthorizationPolicy;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\Access\PermissionService;
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();
$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));

View File

@@ -1,8 +1,7 @@
<?php
use MintyPHP\Router;
$canCreatePermissions = can('permissions.create');
$canUpdatePermissions = can('permissions.update');
$canCreatePermissions = (bool) ($canCreatePermissions ?? false);
?>
<?php
$breadcrumbs = [

View File

@@ -3,12 +3,21 @@
use MintyPHP\Buffer;
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\RoleAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_CREATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$accessServicesFactory = new AccessServicesFactory();
$authorizationService = $accessServicesFactory->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'] ?? [];

View File

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

View File

@@ -1,16 +1,26 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\RoleAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_DELETE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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);

View File

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

View File

@@ -4,12 +4,16 @@
* @var array<int, string> $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');
<?php
$detailsOpenAll = true;
$isReadOnly = $isReadOnly ?? false;
$showDangerZone = can('roles.delete');
$showDangerZone = $canDeleteRole;
$dangerZoneDeleteFormId = $showDangerZone ? 'role-delete-form' : '';
$dangerZoneWarning = t('This will permanently delete this role.');
$dangerZoneActionLabel = t('Delete role');

View File

@@ -1,17 +1,24 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\RoleAuthorizationPolicy;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\Access\PermissionService;
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();
$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));

View File

@@ -1,7 +1,7 @@
<?php
use MintyPHP\Router;
$canCreateRoles = can('roles.create');
$canCreateRoles = (bool) ($canCreateRoles ?? false);
?>
<?php
$breadcrumbs = [

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

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

View File

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

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Branding\BrandingServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,22 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,20 +1,29 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$authorizationService = (new AccessServicesFactory())->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') {

View File

@@ -1,13 +1,14 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,14 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
$authorizationService = (new AccessServicesFactory())->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}");

View File

@@ -1,11 +1,14 @@
<?php
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Support\Guard;
define('MINTY_ALLOW_OUTPUT', true);
Guard::requireLogin();
$authorizationService = (new AccessServicesFactory())->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;
}

View File

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

View File

@@ -3,6 +3,8 @@
/**
* @var array<int, string> $errors
* @var array $form
* @var bool $canManageCustomFields
* @var bool $canManageSso
*/
?>
@@ -50,8 +52,8 @@
<?php
$values = $form ?? [];
$detailsOpenAll = true;
$showCustomFieldsTab = can('custom_fields.manage');
$showSsoTab = can('tenants.sso_manage');
$showCustomFieldsTab = (bool) ($canManageCustomFields ?? false);
$showSsoTab = (bool) ($canManageSso ?? false);
$customFieldTenantUuid = '';
$customFieldDefinitions = [];
require __DIR__ . '/_form.phtml';

View File

@@ -1,14 +1,15 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::CUSTOM_FIELDS_MANAGE);
$authorizationService = (new AccessServicesFactory())->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;
}

View File

@@ -1,14 +1,15 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::CUSTOM_FIELDS_MANAGE);
$authorizationService = (new AccessServicesFactory())->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;
}

View File

@@ -1,14 +1,15 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::CUSTOM_FIELDS_MANAGE);
$authorizationService = (new AccessServicesFactory())->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;
}

View File

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

View File

@@ -1,17 +1,37 @@
<?php
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_DELETE);
$authorizationService = (new AccessServicesFactory())->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);
}

View File

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

View File

@@ -1,13 +1,14 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
$authorizationService = (new AccessServicesFactory())->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');

View File

@@ -1,13 +1,14 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
$authorizationService = (new AccessServicesFactory())->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}");

View File

@@ -1,17 +1,23 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\TenantAuthorizationPolicy;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\Access\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 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));

View File

@@ -1,7 +1,7 @@
<?php
use MintyPHP\Router;
$canCreateTenants = can('tenants.create');
$canCreateTenants = (bool) ($canCreateTenants ?? false);
?>
<?php
$breadcrumbs = [

View File

@@ -1,7 +1,8 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\User\UserAccessPdfService;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Guard;
@@ -10,7 +11,7 @@ define('MINTY_ALLOW_OUTPUT', true);
// Hard gate: user must be logged in and explicitly allowed to generate access PDFs.
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_ACCESS_PDF);
$authorizationService = (new AccessServicesFactory())->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;
}

View File

@@ -1,7 +1,8 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\User\UserAccessPdfService;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Flash;
@@ -11,7 +12,14 @@ define('MINTY_ALLOW_OUTPUT', true);
// Hard gate: user must be logged in and explicitly allowed to generate access PDFs.
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_ACCESS_PDF);
$authorizationService = (new AccessServicesFactory())->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') {

View File

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

View File

@@ -1,14 +1,15 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::API_TOKENS_MANAGE);
$authorizationService = (new AccessServicesFactory())->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 === '') {

View File

@@ -1,14 +1,15 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::API_TOKENS_MANAGE);
$authorizationService = (new AccessServicesFactory())->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) {

View File

@@ -1,11 +1,14 @@
<?php
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();
$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;
}

View File

@@ -1,11 +1,14 @@
<?php
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();
$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;
}

View File

@@ -1,11 +1,14 @@
<?php
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Support\Guard;
define('MINTY_ALLOW_OUTPUT', true);
Guard::requireLogin();
$authorizationService = (new AccessServicesFactory())->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;
}

View File

@@ -1,7 +1,8 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\User\UserAccessTemplateService;
use MintyPHP\Service\User\UserServicesFactory;
@@ -14,12 +15,6 @@ $allowedActions = ['activate', 'deactivate', 'delete', 'send-access'];
if (!in_array($action, $allowedActions, true)) {
Router::json(['ok' => 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();

View File

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

View File

@@ -1,16 +1,31 @@
<?php
use MintyPHP\Router;
use MintyPHP\Http\Request;
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();
$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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,26 @@
<?php
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();
$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);

View File

@@ -1,20 +1,18 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$authorizationService = (new AccessServicesFactory())->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;
}

View File

@@ -1,12 +1,27 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_VIEW);
$authorizationService = (new AccessServicesFactory())->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) {

View File

@@ -1,7 +1,8 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\UserAuthorizationPolicy;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\User\UserAccessTemplateService;
use MintyPHP\Service\User\UserServicesFactory;
@@ -9,11 +10,8 @@ use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$authorizationService = (new AccessServicesFactory())->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;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
<?php
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\ApiBootstrap;
use MintyPHP\Http\ApiResponse;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\User\UserServicesFactory;
define('MINTY_ALLOW_OUTPUT', true);
ApiBootstrap::init();
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$userServicesFactory = new UserServicesFactory();
$userAvatarService = $userServicesFactory->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();

View File

@@ -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<string, mixed> $user
* @param int $userId
* @param \MintyPHP\Service\User\UserAssignmentService $userAssignmentService
* @return array<string, mixed>
*/
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,
],
]);
];
}

View File

@@ -0,0 +1,58 @@
<?php
use MintyPHP\Http\ApiAuth;
use MintyPHP\Http\ApiBootstrap;
use MintyPHP\Http\ApiResponse;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\User\UserServicesFactory;
ApiBootstrap::init();
ApiResponse::requireMethod('POST');
if (!ApiAuth::hasPermission(PermissionService::USERS_SELF_UPDATE)) {
ApiResponse::forbidden();
}
$input = ApiResponse::readJsonBody();
$currentPassword = (string) ($input['current_password'] ?? '');
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$validationErrors = [];
if ($currentPassword === '') {
$validationErrors['current_password'] = ['required'];
}
if ($password === '') {
$validationErrors['password'] = ['required'];
}
if ($password2 === '') {
$validationErrors['password2'] = ['required'];
}
if ($validationErrors) {
ApiResponse::validationError($validationErrors);
}
$userServicesFactory = new UserServicesFactory();
$userAccountService = $userServicesFactory->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();

View File

@@ -0,0 +1,46 @@
<?php
use MintyPHP\Http\ApiBootstrap;
use MintyPHP\Http\ApiResponse;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionService;
ApiBootstrap::init();
ApiResponse::requireMethod('GET');
ApiResponse::requirePermission(PermissionService::PERMISSIONS_VIEW);
$permissionService = (new AccessServicesFactory())->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,
]);

Some files were not shown because too many files have changed in this diff Show More