add chat user roles and login

This commit is contained in:
team 1
2026-05-11 11:33:03 +02:00
parent 3c5de0d8e6
commit 83ac6d600e
9 changed files with 312 additions and 71 deletions

View File

@@ -1,7 +1,7 @@
This patch intentionally removes public/index.html. Important when applying this patch manually:
Reason: Delete public/index.html from the target installation.
If public/index.html remains in the document root, many web servers serve it before public/index.php and the Symfony route / will not reach App\Controller\Chat\ChatController.
When applying this patch manually, delete: The chat root route is now handled by Symfony via App\Controller\Chat\ChatController.
public/index.html If public/index.html remains on disk, the web server may still serve the static file
before Symfony handles /, which would bypass the chat firewall and ROLE_CHAT_USER check.

View File

@@ -11,11 +11,12 @@ security:
firewalls: firewalls:
# 🔐 Admin zuerst! # Admin area: same user provider, separate /admin route space.
admin: admin:
pattern: ^/admin pattern: ^/admin
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
context: retriex_user_area
form_login: form_login:
login_path: admin_login login_path: admin_login
@@ -31,17 +32,49 @@ security:
lifetime: 604800 lifetime: 604800
path: /admin path: /admin
# 🌍 Alles andere ist public (Chat etc.) # Chat area: same user provider, separate route space and role gate.
chat:
pattern: ^/(?:$|chat(?:/|$)|ask-jobs(?:/|$)|ask-sse(?:/|$)|history(?:/|$)|chat-messages/frontend$)
lazy: true
provider: app_user_provider
context: retriex_user_area
form_login:
login_path: chat_login
check_path: chat_login
default_target_path: chat_index
logout:
path: chat_logout
target: chat_login
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
# Everything outside Admin and Chat remains public/static.
main: main:
pattern: ^/ pattern: ^/
security: false security: false
role_hierarchy: role_hierarchy:
ROLE_SUPER_ADMIN: [ROLE_KNOWLEDGE_ADMIN, ROLE_EDITOR, ROLE_USER] ROLE_SUPER_ADMIN: [ROLE_KNOWLEDGE_ADMIN, ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_KNOWLEDGE_ADMIN: [ROLE_EDITOR, ROLE_USER] ROLE_KNOWLEDGE_ADMIN: [ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_EDITOR: [ROLE_USER] ROLE_EDITOR: [ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_ADMIN_AREA: [ROLE_USER]
ROLE_CHAT_USER: [ROLE_USER]
access_control: access_control:
- { path: ^/admin/login$, roles: PUBLIC_ACCESS } - { path: ^/admin/login$, roles: PUBLIC_ACCESS }
- { path: ^/admin/logout$, roles: PUBLIC_ACCESS } - { path: ^/admin/logout$, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_USER } - { path: ^/admin, roles: ROLE_ADMIN_AREA }
- { path: ^/chat/login$, roles: PUBLIC_ACCESS }
- { path: ^/chat/logout$, roles: PUBLIC_ACCESS }
- { path: ^/$, roles: ROLE_CHAT_USER }
- { path: ^/chat$, roles: ROLE_CHAT_USER }
- { path: ^/ask-jobs, roles: ROLE_CHAT_USER }
- { path: ^/ask-sse, roles: ROLE_CHAT_USER }
- { path: ^/history, roles: ROLE_CHAT_USER }
- { path: ^/chat-messages/frontend$, roles: ROLE_CHAT_USER }

View File

@@ -0,0 +1,156 @@
# RetrieX Patch p88 - Chat Security Roles
## Zweck
p88 fuehrt eine getrennte Rollen-/Security-Schicht fuer den Chat ein, ohne eine zweite Userverwaltung anzulegen. Chat und Admin nutzen weiterhin denselben Symfony-User-Provider (`App\Entity\User`), werden aber ueber unterschiedliche Bereichsrollen freigeschaltet.
Der Patch baut auf p87 auf: Der Chat wird bereits ueber einen eigenen Symfony-Controller gerendert. p88 schuetzt diesen Chat-Einstieg und die zugehoerigen Chat-API-Endpunkte nun mit `ROLE_CHAT_USER`.
## Architekturentscheidung
Die Userverwaltung bleibt zentral:
- Entity: `App\Entity\User`
- Provider: `app_user_provider`
- Rollenfeld: bestehendes JSON-Feld `roles`
- Command: bestehender `mto:agent:user:create`
Die Bereichsberechtigungen werden getrennt:
- `ROLE_CHAT_USER` fuer Chat und Chat-API-Endpunkte
- `ROLE_ADMIN_AREA` fuer den Adminbereich
- `ROLE_USER` bleibt nur technische Basisrolle fuer eingeloggte User
Wichtig: `ROLE_USER` ist keine Adminberechtigung mehr. Das ist noetig, weil `User::getRoles()` jedem User automatisch `ROLE_USER` gibt.
## Aenderungen
### `config/packages/security.yaml`
- Admin-Firewall bleibt auf `^/admin` begrenzt.
- Neue Chat-Firewall fuer:
- `/`
- `/chat`
- `/chat/login`
- `/chat/logout`
- `/ask-jobs...`
- `/ask-sse...`
- `/history...`
- `/chat-messages/frontend`
- Beide Firewalls verwenden denselben Provider `app_user_provider`.
- Beide Firewalls verwenden denselben Security-Context `retriex_user_area`, damit die zentrale Userverwaltung und Session sauber gemeinsam genutzt werden koennen.
- Adminzugriff verlangt jetzt `ROLE_ADMIN_AREA` statt `ROLE_USER`.
- Chatzugriff verlangt `ROLE_CHAT_USER`.
### Neue Rollenlogik
```yaml
ROLE_SUPER_ADMIN: [ROLE_KNOWLEDGE_ADMIN, ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_KNOWLEDGE_ADMIN: [ROLE_EDITOR, ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_EDITOR: [ROLE_ADMIN_AREA, ROLE_CHAT_USER, ROLE_USER]
ROLE_ADMIN_AREA: [ROLE_USER]
ROLE_CHAT_USER: [ROLE_USER]
```
Damit gilt:
- Chat-only User: `ROLE_CHAT_USER`
- Admin-only User: `ROLE_ADMIN_AREA`
- Editor/Knowledge/Super Admins behalten Adminzugriff und bekommen Chatzugriff ueber Hierarchie
- `ROLE_USER` alleine reicht fuer keinen Bereich
### Neuer Chat-Login
Neue Dateien:
- `src/Controller/Chat/SecurityController.php`
- `templates/chat/security/login.html.twig`
Routen:
- `/chat/login`
- `/chat/logout`
Der Controller liegt bewusst unter `App\Controller\Chat`, nicht unter `App\Controller\Admin`.
### Admin-Login-Hardening
`src/Controller/Admin/SecurityController.php` leitet eingeloggte User nur noch direkt zum Admin-Dashboard weiter, wenn sie `ROLE_ADMIN_AREA` haben. Ein reiner Chat-User wird dadurch nicht versehentlich in eine Admin-Weiterleitung geschickt.
### Admin-Endpoint-Hardening
`src/Controller/Admin/IngestJobController.php` nutzt fuer den Status-Endpunkt jetzt `ROLE_ADMIN_AREA` statt `ROLE_USER`.
### User-Create-Command
`src/Command/CreateUserCommand.php` wurde angepasst:
- Beschreibung neutralisiert: erstellt nun einen Application-User, nicht nur Admin-User
- Rollenauswahl erweitert um `ROLE_ADMIN_AREA` und `ROLE_CHAT_USER`
- `ROLE_USER` aus der Auswahl entfernt, weil diese Rolle automatisch gesetzt wird und alleine keinen Bereich freischalten soll
## Bewusst nicht geaendert
- Keine neue User-Entity
- Keine neue User-Tabelle
- Keine neue Migration
- Keine Aenderung an RAG, Retrieval, Scoring, Ranking, Shop-Matching oder Prompt-Logik
- Keine Verschiebung bestehender Ask-/SSE-/History-Controller
- Keine Aenderung an `public/assets/js/base.js`
- Keine Aenderung an Chat-Frontend-Verhalten ausser Loginpflicht
## Hinweis zu `public/index.html`
Wie in p87 muss `public/index.html` geloescht sein. Wenn die Datei liegen bleibt, kann der Webserver `/` weiterhin statisch bedienen und Symfony samt Chat-Firewall umgehen.
Im Full-ZIP ist `public/index.html` entfernt. Im Patch-only-ZIP liegt zusaetzlich `DELETE_PUBLIC_INDEX_HTML.txt` als manueller Hinweis.
## Lokale Checks
Ausgefuehrt:
```bash
php -l src/Controller/Chat/SecurityController.php
php -l src/Controller/Chat/ChatController.php
php -l src/Controller/Admin/SecurityController.php
php -l src/Controller/Admin/IngestJobController.php
php -l src/Command/CreateUserCommand.php
python3 - <<'PY'
from pathlib import Path
import yaml
data = yaml.safe_load(Path('config/packages/security.yaml').read_text())
assert data['security']['access_control'][2]['roles'] == 'ROLE_ADMIN_AREA'
assert data['security']['access_control'][5]['roles'] == 'ROLE_CHAT_USER'
assert not Path('public/index.html').exists()
print('p88 structural checks OK')
PY
```
Ergebnis:
- PHP lint OK
- YAML parse OK
- Adminbereich ist nicht mehr ueber `ROLE_USER` freigeschaltet
- Chatbereich ist ueber `ROLE_CHAT_USER` freigeschaltet
- `public/index.html` ist im Full-ZIP entfernt
## Noch in Zielumgebung ausfuehren
```bash
php bin/console cache:clear
php bin/console debug:router | grep -E 'chat_login|chat_logout|chat_index|admin_login|admin_dashboard'
php bin/console debug:config security
php bin/console mto:agent:config:validate
php bin/console mto:agent:regression:test
php bin/console mto:agent:config:audit-source --details
php bin/console mto:agent:config:audit-patterns --details
```
Manuelle Smoke-Tests:
1. Ohne Login `/` aufrufen: Redirect zu `/chat/login`.
2. User mit `ROLE_CHAT_USER` anlegen und einloggen: `/` und `/chat` oeffnen den Chat.
3. Chat-User ruft `/admin` auf: kein Adminzugriff.
4. Bestehender `ROLE_SUPER_ADMIN`, `ROLE_KNOWLEDGE_ADMIN` oder `ROLE_EDITOR` kann weiterhin `/admin` nutzen.
5. Admin-/Editor-User kann den Chat nutzen.
6. `/ask-jobs`, `/ask-sse/{jobId}`, `/history`, `/history/delete`, `/chat-messages/frontend` funktionieren nach Chat-Login.

View File

@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Markdown + Sanitizer -->
<link href="/assets/styles/bootstrap.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="/assets/styles/base.css">
<link rel="shortcut icon" href="https://www.mitho-media.de/media/fc/16/42/1667224106/favicon.ico?ts=1767609928">
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/marked.min.js"></script>
<script src="/assets/js/purify.min.js"></script>
<script src="/assets/js/base.js"></script>
</head>
<body class="bg-black">
<div class="container">
<div class="header">
<div>
<div class="d-flex">
<img src="/assets/img/logo.png" style="max-height: 20px;">
</div>
<div class="text-info fw-bold" style="font-size: 12px" data-chat-message-text="ui.header_title"></div>
</div>
<img src="/assets/img/logo.svg" style="max-height: 20px;">
<div class="spacer"></div>
<button id="clear" class="btn btn-trans" data-chat-message-text="ui.buttons.clear"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash-fill" viewBox="0 0 16 16">
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5M8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5m3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0"/>
</svg></button>
</div>
<div id="ai-cloud" class="ai-cloud d-none"></div>
<div id="chat" class="chat"></div>
<div id="retriex-chat-options" class="retriex-chat-options p-2" data-chat-message-aria-label="ui.options.aria_label">
<label class="retriex-option-toggle" for="toggle-retriex-cards">
<input id="toggle-retriex-cards" type="checkbox">
<span data-chat-message-text="ui.options.status_info"></span>
</label>
</div>
<div class="input-area">
<textarea id="prompt" class="form-control bg-dark" data-chat-message-placeholder="ui.input.prompt_placeholder"></textarea>
<button id="send" class="btn btn-trans" data-chat-message-text="ui.buttons.send"></button>
<button id="abort" class="btn btn-trans" disabled data-chat-message-text="ui.buttons.abort"></button>
</div>
<div class="small mt-2 text-center text-secondary" data-chat-message-text="ui.footer_disclaimer"></div>
</div>
</body>
</html>

View File

@@ -16,7 +16,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand( #[AsCommand(
name: 'mto:agent:user:create', name: 'mto:agent:user:create',
description: 'Creates a new admin user' description: 'Creates a new application user'
)] )]
class CreateUserCommand extends Command class CreateUserCommand extends Command
{ {
@@ -79,7 +79,8 @@ class CreateUserCommand extends Command
'ROLE_SUPER_ADMIN', 'ROLE_SUPER_ADMIN',
'ROLE_KNOWLEDGE_ADMIN', 'ROLE_KNOWLEDGE_ADMIN',
'ROLE_EDITOR', 'ROLE_EDITOR',
'ROLE_USER', 'ROLE_ADMIN_AREA',
'ROLE_CHAT_USER',
], ],
0 0
); );

View File

@@ -50,7 +50,7 @@ final class IngestJobController extends AbstractController
)] )]
public function status(string $id, EntityManagerInterface $em): JsonResponse public function status(string $id, EntityManagerInterface $em): JsonResponse
{ {
$this->denyAccessUnlessGranted('ROLE_USER'); $this->denyAccessUnlessGranted('ROLE_ADMIN_AREA');
$job = $this->findJob($id, $em); $job = $this->findJob($id, $em);

View File

@@ -14,7 +14,7 @@ final class SecurityController extends AbstractController
public function login(AuthenticationUtils $authUtils): Response public function login(AuthenticationUtils $authUtils): Response
{ {
// Wenn bereits eingeloggt → direkt ins Dashboard // Wenn bereits eingeloggt → direkt ins Dashboard
if ($this->getUser()) { if ($this->getUser() !== null && $this->isGranted('ROLE_ADMIN_AREA')) {
return $this->redirectToRoute('admin_dashboard'); return $this->redirectToRoute('admin_dashboard');
} }

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Controller\Chat;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
/**
* Login/logout endpoints for the chat area.
*
* The chat uses the existing App\Entity\User provider and only adds a separate
* route space plus ROLE_CHAT_USER access control. It intentionally stays outside
* App\Controller\Admin so Chat and Admin can evolve independently.
*/
final class SecurityController extends AbstractController
{
#[Route('/chat/login', name: 'chat_login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $authUtils): Response
{
if ($this->getUser() !== null && $this->isGranted('ROLE_CHAT_USER')) {
return $this->redirectToRoute('chat_index');
}
return $this->render('chat/security/login.html.twig', [
'last_username' => $authUtils->getLastUsername(),
'error' => $authUtils->getLastAuthenticationError(),
]);
}
#[Route('/chat/logout', name: 'chat_logout', methods: ['GET'])]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Chat Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="{{ asset('assets/styles/bootstrap.min.css') }}" rel="stylesheet">
<link rel="shortcut icon" href="https://www.mitho-media.de/media/fc/16/42/1667224106/favicon.ico?ts=1767609928">
</head>
<body class="bg-black text-light">
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height:100vh;">
<div class="col-12 col-md-5 col-lg-4">
<div class="card bg-black border-secondary text-info">
<div class="card-body">
<header class="text-center mb-4">
<img src="{{ asset('assets/img/logo.png') }}" style="max-width: 100px;" alt="">
<h1 class="h5 mt-3 mb-1 text-info">RetrieX Chat</h1>
<div class="small text-secondary">Login fuer den Chatbereich</div>
</header>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form method="post" action="{{ path('chat_login') }}">
<div class="mb-3">
<label class="form-label">E-Mail</label>
<input class="form-control bg-dark text-light border-secondary"
name="_username"
value="{{ last_username }}"
autocomplete="email"
required>
</div>
<div class="mb-4">
<label class="form-label">Passwort</label>
<input class="form-control bg-dark text-light border-secondary"
type="password"
name="_password"
autocomplete="current-password"
required>
</div>
<input type="hidden"
name="_csrf_token"
value="{{ csrf_token('authenticate') }}">
<button class="btn btn-outline-info w-100" type="submit">
In den Chat einloggen
</button>
</form>
</div>
</div>
<div class="text-center mt-3 small text-secondary">
powered by mitho&reg; 2026
</div>
</div>
</div>
</div>
</body>
</html>