diff --git a/.gitignore b/.gitignore index 3d480679..7686cb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ src/frontend/dist/ src/backend/main/resources/META-INF/resources/app/ .m2/ tmp/ -.env \ No newline at end of file +.env + +# keycloak secrets +.env-keycloak +keycloak-realm.json \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index a35db942..d96441ff 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,3 +4,4 @@ Jesper Pedersen Ahmed Mordi Hamza Azeem Shashank Singh +Omar Goher diff --git a/README.md b/README.md index 9e51e394..dd6ecc15 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ roles to navigate and get their work done quicker. ## Features - Support ticket system with 5 roles (User, Superuser, TAM, Support, Admin) +- Keycloak Single Sign-On (SSO) authentication - Branding - Public and private messages - Search across ticket numbers and messages @@ -36,6 +37,7 @@ roles to navigate and get their work done quicker. - [Developer guide](https://github.com/mnemosyne-systems/billetsys/blob/main/doc/DEVELOPERS.md) - [Build and run guide](https://github.com/mnemosyne-systems/billetsys/blob/main/doc/BUILDING.md) - [Frontend guide](https://github.com/mnemosyne-systems/billetsys/blob/main/src/frontend/README.md) +- [Keycloak integration guide](https://github.com/mnemosyne-systems/billetsys/blob/main/contrib/keycloak/README.md) See the [releases page](https://github.com/mnemosyne-systems/billetsys/releases) for downloads and release notes. @@ -45,6 +47,7 @@ for downloads and release notes. **billetsys** is built with - [Quarkus](https://quarkus.io/) on [Java 25](https://openjdk.org/) for the backend +- [Keycloak](https://www.keycloak.org/) for identity management and Single Sign-On (SSO) - [PostgreSQL](https://www.postgresql.org) for storage - [React](https://react.dev/) and [TypeScript](https://www.typescriptlang.org/) on the frontend, bundled with [Vite](https://vitejs.dev/) @@ -79,7 +82,15 @@ Copy the example environment file and fill in the secrets: cp .env.example .env ``` -Start the support services (CAP + Valkey) with compose: +Set up Keycloak environment and generate the realm file: +```sh +cd contrib/keycloak +cp .env-keycloak.example .env-keycloak +python generate_realm.py +cd ../.. +``` + +Start the support services (CAP + Valkey + keycloak) with compose: ```sh make platform diff --git a/contrib/keycloak/.env-keycloak.example b/contrib/keycloak/.env-keycloak.example new file mode 100644 index 00000000..d8c1358a --- /dev/null +++ b/contrib/keycloak/.env-keycloak.example @@ -0,0 +1,30 @@ +KEYCLOAK_REALM_NAME=billetsys +KEYCLOAK_SERVER_URL=http://localhost:8180 + +KEYCLOAK_ADMIN_USER=Admin +KEYCLOAK_ADMIN_EMAIL=admin@admin.com +KEYCLOAK_ADMIN_FIRSTNAME=admin +KEYCLOAK_ADMIN_LASTNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin + +KEYCLOAK_ENABLE_REGISTRATION=true + +# if enable reset password & verify email should configure SMTP keycloak mail +KEYCLOAK_ENABLE_VERIFY_EMAIL=true +KEYCLOAK_ENABLE_RESET_PASSWORD=true + +KEYCLOAK_BACKEND_CLIENT_ID=billetsys-backend +KEYCLOAK_BACKEND_CLIENT_SECRET=123456 +BACKEND_URL=http://localhost:8080 + +KEYCLOAK_FRONTEND_CLIENT_ID=billetsys-frontend +FRONTEND_URL=http://localhost:8080 + +KEYCLOAK_MAIL_HOST=smtp.example.com +KEYCLOAK_MAIL_PORT=587 +KEYCLOAK_MAIL_FROM=example@example.com +KEYCLOAK_MAIL_FROM_NAME=example +KEYCLOAK_MAIL_USERNAME=example@example.com +KEYCLOAK_MAIL_PASSWORD=change-me +KEYCLOAK_MAIL_START_TLS=true +KEYCLOAK_MAIL_SSL=false \ No newline at end of file diff --git a/contrib/keycloak/README.md b/contrib/keycloak/README.md new file mode 100644 index 00000000..bf2ac7ce --- /dev/null +++ b/contrib/keycloak/README.md @@ -0,0 +1,156 @@ +# Keycloak + +## Authentication Flow + +``` +[ User Browser ] + | + | 1. Accesses Frontend App + v +[ React App (keycloak-js) ] + | + | 2. Check Authentication (check-sso) + +---> [ Not Authenticated ] ---> Redirect to Keycloak Login Screen + | (Registration / Reset allowed if enabled) + | + | 3. User Logged In + v +[ Keycloak Issues JWT Token ] + | + | 4. Frontend receives Access Token + | 5. Global fetch interceptor injects header on /api/*: + | Authorization: Bearer + v +[ Quarkus Backend (quarkus-oidc) ] + | + | 6. Validates JWT Signature & Claims + | 7. JIT Provisioning & Role Mapping via UserProvisioningService + v +[ PostgreSQL DB (users table) ] +``` + +### Detailed Flow Steps: + +1. **Accessing the App**: The user accesses the React frontend application URL. +2. **SSO Check**: The React app initializes `keycloak-js` on load using `onLoad: "check-sso"`. +3. **Redirection to Keycloak**: If the user is not authenticated, visiting `/login` or protected areas triggers `keycloak.login()`, redirecting them to Keycloak. If user registration is enabled in Keycloak realm settings, users can also register a new account on this page. +4. **Token Issuance**: After successful authentication, Keycloak redirects the user back to the React app with a valid JWT access token. +5. **Automatic Token Attachment**: In `AuthProvider.tsx`, a global `window.fetch` interceptor automatically appends the Bearer token to all local API requests: + ```typescript + headers.set("Authorization", `Bearer ${keycloak.token}`); + ``` +6. **Background Token Refresh**: A 15-second interval loop continuously runs `keycloak.updateToken(30)` to refresh tokens before expiration without disturbing user experience. + +--- + +## User Synchronization & Just-In-Time (JIT) Provisioning + +### The Challenge +When a user registers or logs in via Keycloak, their account exists in Keycloak's database, but does not yet exist in the application's PostgreSQL `users` table. + +### The Solution: Just-In-Time (JIT) Provisioning + +#### **In Frontend** +On each session load, the frontend fetches application state and session data: +```typescript +GET /api/app/session +Headers: { + Authorization: "Bearer " + keycloak.token +} +``` + +#### **In Backend** +1. **Token Extraction**: Quarkus `quarkus-oidc` validates the incoming Bearer JWT token. +2. **Context Resolution (`CurrentUser.java`)**: The JAX-RS endpoint invokes `@Inject CurrentUser currentUser`. +3. **JIT Synchronization (`UserProvisioningService.java`)**: + * Reads JWT claims: `sub` (`keycloakId`), `email`, `preferred_username`, `name` (`fullName`), and `realm_access.roles`. + * Searches the database for an existing user matching `keycloakId` (or `email`). + * **If the user exists**: Updates/syncs their profile fields and maps Keycloak realm roles (`admin`, `support`, `tam`, `superuser`, `user`) to internal application types (`User.type`). + * **If the user does NOT exist**: Automatically provisions and persists a new `User` record in PostgreSQL. +4. **Request Caching**: The `@RequestScoped` `CurrentUser` bean caches the resolved user for the lifetime of the request, eliminating duplicate database lookups. + +--- + +## Configuration + +Authentication behavior can be customized via Keycloak realm settings in `contrib/keycloak/.env-keycloak`: + +### 1. Realm Feature Options +* **User Registration**: `KEYCLOAK_ENABLE_REGISTRATION=true` (Enables or disables self-registration on Keycloak's login page). +* **Email Verification**: `KEYCLOAK_ENABLE_VERIFY_EMAIL=true`. +* **Password Reset**: `KEYCLOAK_ENABLE_RESET_PASSWORD=true`. + +> [!NOTE] +> Enabling **Email Verification** or **Password Reset** requires configuring valid SMTP server credentials in `.env-keycloak`: +> ```env +> KEYCLOAK_MAIL_HOST=smtp.example.com +> KEYCLOAK_MAIL_PORT=587 +> KEYCLOAK_MAIL_FROM=no-reply@example.com +> KEYCLOAK_MAIL_USERNAME=example@example.com +> KEYCLOAK_MAIL_PASSWORD="your-password" +> KEYCLOAK_MAIL_START_TLS=true +> ``` + +### 2. Backend OIDC Properties (`src/backend/main/resources/application.properties`) +```properties +quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://localhost:8180}/realms/${KEYCLOAK_REALM_NAME:billetsys} +quarkus.oidc.client-id=${KEYCLOAK_BACKEND_CLIENT_ID:billetsys-backend} +quarkus.oidc.credentials.secret=${KEYCLOAK_BACKEND_CLIENT_SECRET:123456} +quarkus.oidc.application-type=service +quarkus.oidc.roles.role-claim-path=realm_access/roles +``` + +### 3. Frontend Client Configuration (`src/frontend/src/auth/keycloak.ts`) +```typescript +const keycloak = new Keycloak({ + url: import.meta.env.VITE_KEYCLOAK_URL || "http://localhost:8180", + realm: import.meta.env.VITE_KEYCLOAK_REALM || "billetsys", + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || "billetsys-frontend", +}); +``` + +--- + +## Running the keycloak + +### 1. Set up Environment & Generate Realm +```bash +cd contrib/keycloak +cp .env-keycloak.example .env-keycloak +python generate_realm.py +cd ../.. +``` + +### 2. Start Services via Docker Compose +```bash +docker compose up -d keycloak +``` + +Access keycloak at `http://localhost:8180`. + +--- + +## Helper Scripts & Testing + +```bash +# 1. Create a Keycloak user +./contrib/keycloak/create-user.sh + +# 2. Get a JWT token +./contrib/keycloak/login.sh + +# 3. Call REST API endpoint with Bearer token +TOKEN=$(./contrib/keycloak/login.sh user1 user1) +curl -s "http://localhost:8080/api/user/tickets" -H "Authorization: Bearer $TOKEN" | jq . +``` + +### Role → Endpoint Mapping Reference + +| Role | Access Level | Example Protected Endpoint | +| :--- | :--- | :--- | +| **`admin`** | Administrator | `GET /api/admin/users`, `GET /api/companies` | +| **`support`** | Support Engineer | `GET /api/support/tickets`, `GET /api/support/users` | +| **`superuser`** | Superuser | `GET /api/superuser/tickets`, `GET /api/superuser/users` | +| **`tam`** | Technical Account Manager | `GET /api/user/tickets`, `GET /api/tam/users` | +| **`user`** | Standard User | `GET /api/user/tickets`, `GET /api/user/externals` | +| **`any / none`** | Public | `GET /api/app/session`, `GET /health` | diff --git a/contrib/keycloak/create-user.sh b/contrib/keycloak/create-user.sh new file mode 100755 index 00000000..2f13aa4c --- /dev/null +++ b/contrib/keycloak/create-user.sh @@ -0,0 +1,70 @@ +# Usage: +# ./create-user.sh +# +# Example: +# ./create-user.sh user1 user1@mnemosyne-systems.ai User@123 user John Doe + +USERNAME=$1 +EMAIL=$2 +PASSWORD=$3 +ROLE=$4 +FIRST_NAME=$5 +LAST_NAME=$6 + +# get keycloak admin token +KEYCLOAK_TOKEN=$(curl -s -X POST \ + "http://localhost:8180/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" | jq -r .access_token) + + +# create user +curl -s -X POST \ + "http://localhost:8180/admin/realms/billetsys/users" \ + -H "Authorization: Bearer $KEYCLOAK_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"$USERNAME\", + \"email\": \"$EMAIL\", + \"firstName\": \"$FIRST_NAME\", + \"lastName\": \"$LAST_NAME\", + \"enabled\": true, + \"emailVerified\": true, + \"requiredActions\": [], + \"credentials\": [{ + \"type\": \"password\", + \"value\": \"$PASSWORD\", + \"temporary\": false + }] + }" + + +# get user id +USER_ID=$(curl -s \ + "http://localhost:8180/admin/realms/billetsys/users?username=$USERNAME" \ + -H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.[0].id') + +# get role id +ROLE_ID=$(curl -s \ + "http://localhost:8180/admin/realms/billetsys/roles/$ROLE" \ + -H "Authorization: Bearer $KEYCLOAK_TOKEN" | jq -r '.id') + +# assign role +curl -s -X POST \ + "http://localhost:8180/admin/realms/billetsys/users/$USER_ID/role-mappings/realm" \ + -H "Authorization: Bearer $KEYCLOAK_TOKEN" \ + -H "Content-Type: application/json" \ + -d "[{\"id\": \"$ROLE_ID\", \"name\": \"$ROLE\"}]" + + +echo "" +echo "Created user:" +echo " Username: $USERNAME" +echo " Email: $EMAIL" +echo " First Name: $FIRST_NAME" +echo " Last Name: $LAST_NAME" +echo " Role: $ROLE" +echo " User ID: $USER_ID" \ No newline at end of file diff --git a/contrib/keycloak/generate_realm.py b/contrib/keycloak/generate_realm.py new file mode 100644 index 00000000..cd5f20df --- /dev/null +++ b/contrib/keycloak/generate_realm.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import re + +BASE_DIR = Path(__file__).parent + +ENV_FILE = BASE_DIR / ".env-keycloak" +TEMPLATE_FILE = BASE_DIR / "keycloak-realm.template.json" +OUTPUT_FILE = BASE_DIR / "keycloak-realm.json" + + +def load_env_file(env_path): + values = {} + + for line in Path(env_path).read_text().splitlines(): + line = line.strip() + + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + + key, value = line.split("=", 1) + + # Remove surrounding quotes + value = value.strip().strip('"').strip("'") + + values[key.strip()] = value + + return values + + +def replace_placeholders(template, values): + pattern = re.compile(r"\$\{([A-Z0-9_]+)\}") + + def replacer(match): + key = match.group(1) + return values.get(key, match.group(0)) + + return pattern.sub(replacer, template) + + +def main(): + env_values = load_env_file(ENV_FILE) + + template_content = Path(TEMPLATE_FILE).read_text() + + output_content = replace_placeholders( + template_content, + env_values + ) + + Path(OUTPUT_FILE).write_text(output_content) + + print(f"Generated {OUTPUT_FILE} successfully.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/contrib/keycloak/keycloak-realm.template.json b/contrib/keycloak/keycloak-realm.template.json new file mode 100644 index 00000000..d2093351 --- /dev/null +++ b/contrib/keycloak/keycloak-realm.template.json @@ -0,0 +1,128 @@ +{ + "realm": "${KEYCLOAK_REALM_NAME}", + "enabled": true, + "sslRequired": "external", + "defaultRoles": ["user"], + "verifyEmail": "${KEYCLOAK_ENABLE_VERIFY_EMAIL}", + "registrationAllowed": "${KEYCLOAK_ENABLE_REGISTRATION}", + "resetPasswordAllowed": "${KEYCLOAK_ENABLE_RESET_PASSWORD}", + "smtpServer": { + "host": "${KEYCLOAK_MAIL_HOST}", + "port": "${KEYCLOAK_MAIL_PORT}", + "from": "${KEYCLOAK_MAIL_FROM}", + "fromDisplayName": "${KEYCLOAK_MAIL_FROM_NAME}", + "auth": "true", + "user": "${KEYCLOAK_MAIL_USERNAME}", + "password": "${KEYCLOAK_MAIL_PASSWORD}", + "starttls": "${KEYCLOAK_MAIL_START_TLS}", + "ssl": "${KEYCLOAK_MAIL_SSL}" + }, + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user role" + }, + { + "name": "support", + "description": "Support role" + }, + { + "name": "superuser", + "description": "superuser role" + }, + { + "name": "tam", + "description": "tam role" + }, + { + "name": "admin", + "description": "Administrator role" + } + ] + }, + "clients": [ + { + "clientId": "${KEYCLOAK_BACKEND_CLIENT_ID}", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "${KEYCLOAK_BACKEND_CLIENT_SECRET}", + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "redirectUris": [ + "${BACKEND_URL}/*" + ], + "webOrigins": [ + "${BACKEND_URL}" + ] + }, + { + "clientId": "${KEYCLOAK_FRONTEND_CLIENT_ID}", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "bearerOnly": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "${FRONTEND_URL}/*" + ], + "webOrigins": [ + "${FRONTEND_URL}" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + } + } + ], + "users": [ + { + "username": "${KEYCLOAK_ADMIN_USER}", + "enabled": true, + "email": "${KEYCLOAK_ADMIN_EMAIL}", + "firstName": "${KEYCLOAK_ADMIN_FIRSTNAME}", + "lastName": "${KEYCLOAK_ADMIN_LASTNAME}", + "credentials": [ + { + "type": "password", + "value": "${KEYCLOAK_ADMIN_PASSWORD}" + } + ], + "realmRoles": ["admin"] + }, + { + "username": "support1", + "enabled": true, + "email": "support@support.com", + "firstName": "Sarah", + "lastName": "Johnson", + "credentials": [ + { + "type": "password", + "value": "support1" + } + ], + "realmRoles": ["support"] + }, + { + "username": "user1", + "enabled": true, + "email": "user@user.com", + "firstName": "John", + "lastName": "Doe", + "credentials": [ + { + "type": "password", + "value": "user1" + } + ], + "realmRoles": ["user"] + } + ] +} diff --git a/contrib/keycloak/login.sh b/contrib/keycloak/login.sh new file mode 100755 index 00000000..237fa2f7 --- /dev/null +++ b/contrib/keycloak/login.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Usage: +# ./login.sh +# +# Example: +# ./login.sh omar User@123 + +USERNAME=$1 +PASSWORD=$2 + +KEYCLOAK_URL="http://localhost:8180" +REALM="billetsys" +CLIENT_ID="billetsys-backend" + +TOKEN=$(curl -s -X POST \ + "$KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=123456" \ + -d "username=$USERNAME" \ + -d "password=$PASSWORD" | jq -r .access_token) + +echo "$TOKEN" \ No newline at end of file diff --git a/doc/BUILDING.md b/doc/BUILDING.md index 192ab9e0..df6f06ca 100644 --- a/doc/BUILDING.md +++ b/doc/BUILDING.md @@ -55,6 +55,7 @@ The root `docker-compose.yml` starts: - `cap` on `http://localhost:3000` - `valkey` for CAP storage +- `keycloak` on `http://localhost:8180` Then start billetsys itself in development mode: @@ -173,5 +174,5 @@ The targets are * `docs` - Generate documentation * `db-drop` - Drop the database * `db-create` - Create the database -* `platform` - Start compose services (CAP + Valkey) +* `platform` - Start compose services (CAP + Valkey + Keycloak) * `full` - Run everything (db-drop, db-create, platform, clean, format, test, docs, run) diff --git a/doc/DEVELOPERS.md b/doc/DEVELOPERS.md index 891f8c5f..66ebc779 100644 --- a/doc/DEVELOPERS.md +++ b/doc/DEVELOPERS.md @@ -185,14 +185,16 @@ psql -h localhost -p 5432 -U ticketdb ticketdb #### billetsys -``` sh -cd /usr/local -git clone https://github.com/mnemosyne-systems/billetsys.git -cd billetsys -``` - See `BUILDING.md` for the current development, build, test, and frontend integration commands. +## Keycloak OIDC Authentication + +Billetsys uses **Keycloak** for Single Sign-On (SSO) and bearer token authentication. + +For full setup details, environment variables, realm generation, helper scripts (`create-user.sh` & `login.sh`), CLI testing, and role-to-endpoint mappings, see the dedicated guide: + +**[Keycloak Integration & Testing Guide](../contrib/keycloak/README.md)** + ## Logging levels | Level | Description | diff --git a/doc/manual/en/22-security.md b/doc/manual/en/22-security.md index a9e09844..1a944667 100644 --- a/doc/manual/en/22-security.md +++ b/doc/manual/en/22-security.md @@ -10,44 +10,62 @@ The **Security** model in billetsys controls who can sign in, what they can acce Billetsys is built around differentiated roles and scoped access. Security is what makes that possible. It ensures that users reach the right parts of the system and that ticket, company, and administrative data are not treated as universally available. -## Authentication +## Keycloak -Authentication is the starting point of access control. A user signs in with account credentials, and the application uses that identity to establish an active session. +Billetsys uses **Keycloak** as its centralized identity provider. -This allows billetsys to recognize the user across requests and present the correct role-specific experience. +### Key Benefits: +* **Centralized Credentials**: Manage your credentials securely through Keycloak. +* **Automatic Session Renewal**: Your session stays active while you work without interrupting your tasks. +* **Role-Based Access Control**: Capabilities and navigation are automatically tailored based on your assigned Keycloak realm roles. +* **Self-Service Security**: Update passwords securely using Keycloak's self-service workflows. -## Authorization +--- -After sign-in, authorization determines what the user is allowed to do. This is where the role model becomes active. +## How to Sign In -In practice, authorization defines access across areas such as: +1. **Access Billetsys**: Open the application URL in your browser. +2. **Automatic SSO Redirection**: If you are not signed in, visiting protected areas will automatically redirect you to the official Keycloak SSO portal. +3. **Enter Credentials**: Enter your username and password on the Keycloak login screen and click **Sign In**. -* User-facing ticket views -* Company-scoped coordination views -* Support work areas -* Administrative configuration -* Reporting and oversight pages +![Keycloak Login Screen](images/keycloak-login.png){ width=100% } -This is one of the core design principles of billetsys because different roles are intentionally meant to see different scopes of work. +4. **Role Dashboard Routing**: After successful authentication, Keycloak redirects you back to your role-specific home area (e.g., Support Workbench, Admin Dashboard, or Customer Ticket Portal). -## Session behavior +--- -Security in billetsys also depends on session handling. Once a user is authenticated, the session keeps the application connected to that identity until the user signs out or the session ends. +## Account Provisioning & First-Time Sign-In -This makes the system usable in day-to-day work while still preserving the idea of authenticated access. +You do not need to manually pre-register inside Billetsys. On your first sign-in, Keycloak passes your authenticated profile (name, email, unique ID, and assigned roles) to Billetsys, which automatically provisions your account via **Just-In-Time (JIT) provisioning**. -## Logout and account boundaries +Subsequent logins dynamically sync any updated profile details and role assignments from Keycloak. -Signing out is an important part of the same model. It closes the active session and ensures that the next interaction requires authentication again. +--- -This is especially important in environments where multiple people may use the same device or shared workstation patterns exist. +## Active Sessions & Inactivity Timeout -## Password protection +* **Silent Token Renewal**: While actively working in Billetsys, background processes automatically renew your security tokens every 15 seconds. -Passwords are part of the security foundation of the system, but billetsys treats them as part of a broader access-control model rather than a standalone feature. +--- -Together with session handling and role enforcement, password protection helps keep the application aligned with the responsibilities assigned to each user type. +## Self-Service Password Updates -## Why it matters +To change your password: +1. Click your profile name/avatar in the top navigation bar. +2. Select **Change Password** (or navigate to `profile/password`). +3. You will be redirected to Keycloak's secure **Update Password** page. +4. Enter your new password, confirm it, and submit. -Without security controls, billetsys would lose the structure that makes its role model and customer scoping useful. With authentication, authorization, and session control in place, the platform can support collaboration while still preserving boundaries between users, roles, and organizations. +![Keycloak Password Update Screen](images/keycloak-password-update.png){ width=100% } + +Upon completion, Keycloak safely redirects you back to Billetsys with a success confirmation toast. + +--- + +## Signing Out (Logout) + +To end your session securely: +1. Click your profile avatar in the top navigation bar. +2. Click **Sign out**. + +Signing out invalidates your Keycloak session tokens across the application. diff --git a/doc/manual/en/25-password-reset.md b/doc/manual/en/25-password-reset.md index e3285171..ff0f011f 100644 --- a/doc/manual/en/25-password-reset.md +++ b/doc/manual/en/25-password-reset.md @@ -2,18 +2,21 @@ # Password Reset -The **Password Reset** flow provides a secure, self-service way for users to regain access to their accounts if they forget their credentials. +The **Password Reset** flow provides a secure, self-service way for users to regain access to their accounts if they forget their credentials. In Billetsys, user credentials and password policies are centrally managed by **Keycloak**. ## Purpose -To maintain security while minimizing support overhead, billetsys allows users to independently reset their passwords. This flow is protected by CAP (CAPTCHA) integration to prevent abuse, such as automated spam or enumeration attacks. +To maintain security while minimizing support overhead, billetsys integrates with Keycloak's self-service credential management workflows. Users can independently reset or update their passwords directly through Keycloak. -## The Reset Flow +## Keycloak Password Reset Flow + +1. **Initiating Reset**: Click "Forgot your password?" on the login page or navigate directly to the Keycloak authentication screen. +2. **Keycloak Verification**: Keycloak prompts for your registered email address or username. +3. **Email Delivery**: Keycloak sends a time-limited password reset link to your email address (if SMTP is configured in Keycloak). +4. **Updating Password**: Clicking the secure link takes you to Keycloak's **Update Password** page where you specify your new password. + +--- -1. **Requesting a Reset**: A user initiates the process from the login page by clicking "Forgot your password?". They are prompted to enter their email address. -2. **CAP Verification**: Before the request is accepted, the user must solve a CAP challenge. This ensures that the request is coming from a human and protects the system from automated bots. -3. **Email Delivery**: If the email matches an active account, billetsys sends an email containing the user's username and a secure, time-limited reset link. The email format follows that user's profile preference when one has been configured. -4. **Setting a New Password**: Clicking the link takes the user to a secure reset page where they can establish a new password and immediately regain access to the application. ## Security Considerations diff --git a/doc/manual/en/97-acknowledgement.md b/doc/manual/en/97-acknowledgement.md index 1e6b5300..d6bd9ec7 100644 --- a/doc/manual/en/97-acknowledgement.md +++ b/doc/manual/en/97-acknowledgement.md @@ -11,6 +11,7 @@ Jesper Pedersen Ahmed Mordi Hamza Azeem Shashank Singh +Omar Goher ``` ## Committers diff --git a/doc/manual/en/images/keycloak-login.png b/doc/manual/en/images/keycloak-login.png new file mode 100644 index 00000000..e97c6b6d Binary files /dev/null and b/doc/manual/en/images/keycloak-login.png differ diff --git a/doc/manual/en/images/keycloak-password-update.png b/doc/manual/en/images/keycloak-password-update.png new file mode 100644 index 00000000..49b49151 Binary files /dev/null and b/doc/manual/en/images/keycloak-password-update.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 8e0c27e0..a801243f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,5 +25,24 @@ services: retries: 5 restart: unless-stopped + keycloak: + image: quay.io/keycloak/keycloak:26.7.0 + container_name: keycloak + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_RELATIVE_PATH: / + ports: + - "8180:8080" + volumes: + - ./contrib/keycloak/keycloak-realm.json:/opt/keycloak/data/import/realm.json + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + volumes: valkey-data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 4de25b2d..08cf028b 100644 --- a/pom.xml +++ b/pom.xml @@ -131,11 +131,20 @@ rest-assured test + + io.quarkus + quarkus-oidc + io.quarkus quarkus-jdbc-h2 test + + io.quarkus + quarkus-test-security-jwt + test + org.webjars.npm chart.js diff --git a/src/backend/main/java/ai/mnemosyne_systems/model/User.java b/src/backend/main/java/ai/mnemosyne_systems/model/User.java index 5ed886b9..7b8684a9 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/model/User.java +++ b/src/backend/main/java/ai/mnemosyne_systems/model/User.java @@ -40,6 +40,9 @@ public class User extends PanacheEntityBase { @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq") public Long id; + @Column(name = "keycloak_id", unique = true, nullable = true) + public String keycloakId; + @Column(nullable = false) public String name; @@ -69,7 +72,7 @@ public class User extends PanacheEntityBase { @Column(name = "user_type", nullable = false) public String type; - @Column(name = "password_hash", nullable = false) + @Column(name = "password_hash") public String passwordHash; @Column(name = "logo_base64", columnDefinition = "text") diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AdminUserApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AdminUserApiResource.java index ebbdabd5..ff8c91b2 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AdminUserApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AdminUserApiResource.java @@ -12,8 +12,8 @@ import ai.mnemosyne_systems.model.Country; import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; @@ -25,13 +25,13 @@ @Path("/api/admin/users") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("admin") public class AdminUserApiResource { @GET @Transactional - public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam("authUserIdV3") String auth, - @QueryParam("companyId") Long companyId) { - OwnerResource.requireAdmin(auth); + public UserDirectoryApiModels.DirectoryListResponse list(@QueryParam("companyId") Long companyId) { + List companies = Company.list("order by name"); boolean unassignedSelected = companyId != null && companyId.longValue() == 0L; Company selectedCompany = unassignedSelected ? null : selectCompany(companies, companyId); @@ -52,10 +52,9 @@ public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam("authUserI @GET @Path("/bootstrap") @Transactional - public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam("authUserIdV3") String auth, - @QueryParam("userId") Long userId, @QueryParam("companyId") Long companyId, - @QueryParam("countryId") Long countryId) { - OwnerResource.requireAdmin(auth); + public UserDirectoryApiModels.UserFormResponse bootstrap(@QueryParam("userId") Long userId, + @QueryParam("companyId") Long companyId, @QueryParam("countryId") Long countryId) { + List companies = Company.list("order by name"); User user = userId == null ? new User() : User.findById(userId); if (userId != null && user == null) { @@ -102,9 +101,8 @@ public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam("authUserI @GET @Path("/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse detail(@CookieParam("authUserIdV3") String auth, - @PathParam("id") Long id) { - OwnerResource.requireAdmin(auth); + public UserDirectoryApiModels.UserDetailResponse detail(@PathParam("id") Long id) { + User user = User.findById(id); if (user == null) { throw new NotFoundException(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java index e81d5e10..a826c483 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AppSessionResource.java @@ -11,23 +11,29 @@ import ai.mnemosyne_systems.infra.BrandingProvider; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import jakarta.inject.Inject; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import java.util.List; +import jakarta.annotation.security.PermitAll; @Path("/api/app/session") @Produces(MediaType.APPLICATION_JSON) +@PermitAll public class AppSessionResource { @Inject BrandingProvider brandingProvider; + @Inject + CurrentUser currentUser; + @GET - public SessionResponse session(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { + public SessionResponse session() { + String installationCompanyName = brandingProvider.installationCompanyName(); String installationLogoBase64 = brandingProvider.installationLogoBase64(); String installationBackgroundBase64 = brandingProvider.installationBackgroundBase64(); @@ -47,7 +53,10 @@ public SessionResponse session(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) String installationTamRoleColor = brandingProvider.installationTamRoleColor(); String installationUserRoleColor = brandingProvider.installationUserRoleColor(); String installationExternalRoleColor = brandingProvider.installationExternalRoleColor(); - User user = AuthHelper.findUser(auth); + + // resolve user from Keycloak + User user = currentUser.getOrNull(); + if (user == null) { return new SessionResponse(false, null, null, null, null, null, installationCompanyName, installationLogoBase64, installationBackgroundBase64, installationHeaderFooterColor, diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleApiResource.java index bd332106..6822c346 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleApiResource.java @@ -12,9 +12,11 @@ import ai.mnemosyne_systems.model.Attachment; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; @@ -22,9 +24,7 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -32,14 +32,18 @@ @Path("/api/articles") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class ArticleApiResource { + @Inject + CurrentUser currentUser; + @GET @Transactional - public ArticleListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("page") Integer page, - @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, @QueryParam("dir") String dir, - @QueryParam("q") String q) { - User user = requireUser(auth); + public ArticleListResponse list(@QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, + @QueryParam("sort") String sort, @QueryParam("dir") String dir, @QueryParam("q") String q) { + User user = currentUser.get(); + ArticleResource.ensureSampleArticle(); String needle = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); List allItems = Article.
list("order by lower(title), id").stream() @@ -59,17 +63,15 @@ public ArticleListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("/bootstrap") - public ArticleBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = requireUser(auth); + public ArticleBootstrapResponse bootstrap() { + User user = currentUser.get(); return new ArticleBootstrapResponse(ArticleResource.canEdit(user), AuthHelper.isAdmin(user)); } @GET @Path("/suggest") @Transactional - public ArticleSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("q") @DefaultValue("") String q) { - requireUser(auth); + public ArticleSuggestionResponse suggest(@QueryParam("q") @DefaultValue("") String q) { ArticleResource.ensureSampleArticle(); String needle = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); List
all = Article.
list("order by id desc"); @@ -92,8 +94,8 @@ public ArticleSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) St @GET @Path("/{id}") @Transactional - public ArticleDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public ArticleDetailResponse detail(@PathParam("id") Long id) { + User user = currentUser.get(); ArticleResource.ensureSampleArticle(); Article article = ArticleResource.findArticleWithAttachments(id); if (article == null) { @@ -105,14 +107,6 @@ public ArticleDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String ArticleResource.canEdit(user) ? "/articles/" + article.id + "/edit" : null, attachments); } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - private ArticleAttachment toAttachmentResponse(Attachment attachment) { return new ArticleAttachment(attachment.id, attachment.name, attachment.mimeType, attachment.sizeLabel(), "/attachments/" + attachment.id + "/data"); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleResource.java index 89337148..0ba8abba 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ArticleResource.java @@ -13,12 +13,14 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.hibernate.orm.panache.Panache; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.NotFoundException; @@ -40,6 +42,7 @@ @Path("/articles") @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class ArticleResource { @jakarta.inject.Inject ai.mnemosyne_systems.service.EventService eventService; @@ -49,25 +52,28 @@ public class ArticleResource { private static final String SAMPLE_BODY = "## Welcome to billetsys\n\n- Open a ticket from the Tickets menu\n- Use Markdown in messages\n- Attach files with the attachment picker"; private static final String LEGACY_SAMPLE_BODY_PREFIX = "# Getting Started"; + @Inject + CurrentUser currentUser; + @GET @Transactional - public Response list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireLoggedIn(auth); + public Response list() { + currentUser.get(); ensureSampleArticle(); return Response.seeOther(URI.create("/articles")).build(); } @GET @Path("/create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireCreateEdit(auth); + public Response createForm() { + requireCreateEdit(); return Response.seeOther(URI.create("/articles/new")).build(); } @GET @Path("/{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireCreateEdit(auth); + public Response editForm(@PathParam("id") Long id) { + requireCreateEdit(); if (findArticleWithAttachments(id) == null) { throw new NotFoundException(); } @@ -76,8 +82,8 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @GET @Path("/{id}") - public Response view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireView(auth); + public Response view(@PathParam("id") Long id) { + requireView(); ensureSampleArticle(); if (findArticleWithAttachments(id) == null) { throw new NotFoundException(); @@ -88,9 +94,9 @@ public Response view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPara @POST @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - requireCreateEdit(auth); + public Response create(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { + requireCreateEdit(); + User user = currentUser.get(); String title = AttachmentHelper.readFormValue(input, "title"); String tags = AttachmentHelper.readFormValue(input, "tags"); String body = AttachmentHelper.readFormValue(input, "body"); @@ -100,8 +106,8 @@ public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, article.tags = tags == null ? null : tags.trim(); article.body = body.trim(); article.persist(); - eventService.record(article.id, ai.mnemosyne_systems.model.event.EventConstants.ARTICLE_CREATED, null, - AuthHelper.findUser(auth).id, "Article created"); + eventService.record(article.id, ai.mnemosyne_systems.model.event.EventConstants.ARTICLE_CREATED, null, user.id, + "Article created"); List attachments = storeAttachments(article, AttachmentHelper.readAttachments(input, "attachments")); article.body = resolveInlineAttachmentUrls(article.body, attachments); @@ -112,9 +118,9 @@ public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path("/{id}") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - requireCreateEdit(auth); + public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + MultipartFormDataInput input) { + requireCreateEdit(); Article article = Article .find("select distinct a from Article a left join fetch a.attachments where a.id = ?1", id) .firstResult(); @@ -137,15 +143,15 @@ public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPa @POST @Path("/{id}/delete") @Transactional - public Response delete(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + @RolesAllowed("admin") + public Response delete(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Article article = Article.findById(id); if (article == null) { throw new NotFoundException(); } - eventService.record(article.id, ai.mnemosyne_systems.model.event.EventConstants.ARTICLE_DELETED, null, - AuthHelper.findUser(auth).id, "Article deleted"); + User user = currentUser.get(); + eventService.record(article.id, ai.mnemosyne_systems.model.event.EventConstants.ARTICLE_DELETED, null, user.id, + "Article deleted"); article.delete(); return ReactRedirectSupport.redirect(client, "/articles"); } @@ -227,28 +233,29 @@ static Article findArticleWithAttachments(Long id) { .firstResult(); } - private User requireLoggedIn(String auth) { - User user = AuthHelper.findUser(auth); + private User requireLoggedIn() { + User user = currentUser.get(); if (user == null) { throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); } return user; } - private User requireView(String auth) { - return requireLoggedIn(auth); + private User requireView() { + return requireLoggedIn(); } - private User requireCreateEdit(String auth) { - User user = requireLoggedIn(auth); + private User requireCreateEdit() { + User user = currentUser.get(); if (!canEdit(user)) { + // TODO return ForbiddenException() ?! throw new WebApplicationException(Response.seeOther(URI.create("/articles")).build()); } return user; } - private User requireAdmin(String auth) { - User user = requireLoggedIn(auth); + private User requireAdmin() { + User user = requireLoggedIn(); if (!AuthHelper.isAdmin(user)) { throw new WebApplicationException(Response.seeOther(URI.create("/articles")).build()); } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentApiResource.java index cecff1ae..988a3cf3 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentApiResource.java @@ -13,9 +13,12 @@ import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; @@ -32,16 +35,18 @@ @Path("/api/attachments") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class AttachmentApiResource { + @Inject + CurrentUser currentUser; + @GET @Path("/{id}") @Transactional - public AttachmentResponse view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } + public AttachmentResponse view(@PathParam("id") Long id) { + User user = currentUser.get(); + Attachment attachment = Attachment.findById(id); if (attachment == null) { throw new NotFoundException(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentResource.java index 07f01a06..6c4be013 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AttachmentResource.java @@ -13,14 +13,15 @@ import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; -import jakarta.ws.rs.CookieParam; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.net.URI; @@ -29,16 +30,17 @@ @Path("/attachments") @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class AttachmentResource { + @Inject + CurrentUser currentUser; + @GET @Path("/{id}/data") @Produces("*/*") - public Response data(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } + public Response data(@PathParam("id") Long id) { + User user = currentUser.get(); Attachment attachment = Attachment.findById(id); if (attachment == null) { throw new NotFoundException(); @@ -57,11 +59,9 @@ public Response data(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPara @GET @Path("/{id}") - public Response view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } + public Response view(@PathParam("id") Long id) { + User user = currentUser.get(); + Attachment attachment = Attachment.findById(id); if (attachment == null) { throw new NotFoundException(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/AuthResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/AuthResource.java index cafb363a..af29e101 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/AuthResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/AuthResource.java @@ -6,6 +6,7 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ +// We don't need it anymore. package ai.mnemosyne_systems.resource; import ai.mnemosyne_systems.model.User; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryApiResource.java index 93b4611d..e67b7b6a 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryApiResource.java @@ -12,9 +12,12 @@ import ai.mnemosyne_systems.model.Category; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; @@ -28,12 +31,15 @@ @Path("/api/categories") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed("admin") public class CategoryApiResource { + @Inject + CurrentUser currentUser; + @GET @Transactional - public CategoryListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public CategoryListResponse list() { List items = Category . list("order by name asc").stream().map(category -> new CategorySummary(category.id, category.name, CategoryResource.firstLinePlainText(category.description), category.isDefault)) @@ -43,16 +49,14 @@ public CategoryListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @GET @Path("/bootstrap") - public CategoryBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public CategoryBootstrapResponse bootstrap() { return new CategoryBootstrapResponse(true); } @GET @Path("/{id}") @Transactional - public CategoryDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public CategoryDetailResponse detail(@PathParam("id") Long id) { Category category = CategoryResource.findCategoryWithAttachments(id); if (category == null) { throw new NotFoundException(); @@ -62,14 +66,6 @@ public CategoryDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String "/categories/" + category.id + "/edit", attachments); } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - private CategoryAttachment toAttachmentResponse(Attachment attachment) { return new CategoryAttachment(attachment.id, attachment.name, attachment.mimeType, attachment.sizeLabel(), "/attachments/" + attachment.id + "/data"); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryResource.java index cca419fa..178727f6 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CategoryResource.java @@ -13,12 +13,15 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.hibernate.orm.panache.Panache; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.NotFoundException; @@ -39,27 +42,28 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("admin") public class CategoryResource { @jakarta.inject.Inject ai.mnemosyne_systems.service.EventService eventService; + @Inject + CurrentUser currentUser; + @GET - public Response list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response list() { return Response.seeOther(URI.create("/categories")).build(); } @GET @Path("/create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response createForm() { return Response.seeOther(URI.create("/categories/new")).build(); } @GET @Path("/{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response editForm(@PathParam("id") Long id) { if (findCategoryWithAttachments(id) == null) { throw new NotFoundException(); } @@ -68,8 +72,7 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @GET @Path("/{id}") - public Response view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response view(@PathParam("id") Long id) { if (findCategoryWithAttachments(id) == null) { throw new NotFoundException(); } @@ -79,9 +82,7 @@ public Response view(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPara @POST @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - requireAdmin(auth); + public Response create(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { String name = AttachmentHelper.readFormValue(input, "name"); String description = AttachmentHelper.readFormValue(input, "description"); String isDefault = AttachmentHelper.readFormValue(input, "isDefault"); @@ -97,8 +98,9 @@ public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, category.description = description == null ? "" : description.trim(); category.isDefault = makeDefault; category.persist(); + User user = currentUser.get(); eventService.record(category.id, ai.mnemosyne_systems.model.event.EventConstants.CATEGORY_CREATED, null, - AuthHelper.findUser(auth).id, "Category created"); + user.id, "Category created"); List attachments = storeAttachments(category, AttachmentHelper.readAttachments(input, "attachments")); category.description = resolveInlineAttachmentUrls(category.description, attachments); @@ -109,9 +111,8 @@ public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path("/{id}") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - requireAdmin(auth); + public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + MultipartFormDataInput input) { Category category = Category .find("select distinct c from Category c left join fetch c.attachments where c.id = ?1", id) .firstResult(); @@ -158,16 +159,15 @@ static String firstLinePlainText(String description) { @POST @Path("/{id}/delete") @Transactional - public Response delete(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + public Response delete(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Category category = Category.findById(id); if (category == null) { throw new NotFoundException(); } Attachment.delete("category", category); + User user = currentUser.get(); eventService.record(category.id, ai.mnemosyne_systems.model.event.EventConstants.CATEGORY_DELETED, null, - AuthHelper.findUser(auth).id, "Category deleted"); + user.id, "Category deleted"); category.delete(); return ReactRedirectSupport.redirect(client, "/categories"); } @@ -210,11 +210,4 @@ private void clearDefaults() { } } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyApiResource.java index e8269fe3..8f588a3e 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyApiResource.java @@ -15,30 +15,27 @@ import ai.mnemosyne_systems.model.Level; import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; -import ai.mnemosyne_systems.util.AuthHelper; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import java.time.LocalDate; -import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @Path("/api/companies") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("admin") public class CompanyApiResource { @GET @Transactional - public CompanyListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public CompanyListResponse list() { List companies = Company.list("lower(name) <> lower(?1) order by name", "mnemosyne systems"); return new CompanyListResponse("/companies/new", companies.stream().map(this::toSummary).toList()); } @@ -46,8 +43,7 @@ public CompanyListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("/bootstrap") @Transactional - public CompanyBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public CompanyBootstrapResponse bootstrap() { return new CompanyBootstrapResponse(optionCountries(), optionTimezones(), optionUsers(User.TYPE_SUPERUSER, null), optionUsers(User.TYPE_USER, null), optionUsers(User.TYPE_TAM, null), optionEntitlements(), optionLevels(), defaultCountryId(), @@ -59,8 +55,7 @@ public CompanyBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) S @GET @Path("/{id}") @Transactional - public CompanyDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public CompanyDetailResponse detail(@PathParam("id") Long id) { Company company = Company.find("select distinct c from Company c left join fetch c.users where c.id = ?1", id) .firstResult(); if (company == null) { @@ -82,7 +77,7 @@ public CompanyDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String selectedTamOptions.stream().map(UserOption::id).toList()); List superuserOptions = optionUsers(User.TYPE_SUPERUSER, company.id); List userOptions = optionUsers(User.TYPE_USER, company.id); - CompanyBootstrapResponse bootstrap = bootstrap(auth); + CompanyBootstrapResponse bootstrap = bootstrap(); return new CompanyDetailResponse(company.id, company.name, company.address1, company.address2, company.city, company.state, company.zip, company.phoneNumber, company.country == null ? null : company.country.id, company.country == null ? null : company.country.name, @@ -185,14 +180,6 @@ private boolean isEntitlementExpired(CompanyEntitlement entitlement) { return LocalDate.now().isAfter(endDate); } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record CompanyListResponse(String createPath, List items) { } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyResource.java index d2d6fcb1..6e41024a 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/CompanyResource.java @@ -17,12 +17,15 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -44,27 +47,28 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("admin") public class CompanyResource { + @Inject + CurrentUser currentUser; + @jakarta.inject.Inject ai.mnemosyne_systems.service.EventService eventService; @GET - public Response listCompanies(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response listCompanies() { return Response.seeOther(URI.create("/companies")).build(); } @GET @Path("/create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response createForm() { return Response.seeOther(URI.create("/companies/new")).build(); } @GET @Path("{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response editForm(@PathParam("id") Long id) { if (Company.findById(id) == null) { throw new NotFoundException(); } @@ -73,8 +77,7 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @GET @Path("{id}") - public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response viewCompany(@PathParam("id") Long id) { if (Company.findById(id) == null) { throw new NotFoundException(); } @@ -84,8 +87,7 @@ public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @POST @Path("") @Transactional - public Response createCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, + public Response createCompany(@HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, @FormParam("address1") String address1, @FormParam("address2") String address2, @FormParam("city") String city, @FormParam("state") String state, @FormParam("zip") String zip, @FormParam("countryId") Long countryId, @FormParam("timezoneId") Long timezoneId, @@ -95,7 +97,6 @@ public Response createCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @FormParam("entitlementDates") java.util.List entitlementDates, @FormParam("entitlementDurations") java.util.List entitlementDurations, @FormParam("phoneNumber") String phoneNumber) { - requireAdmin(auth); if (name == null || name.isBlank()) { throw new BadRequestException("Name is required"); } @@ -111,8 +112,9 @@ public Response createCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, company.users = resolveUsers(userIds, tamIds, null); company.phoneNumber = phoneNumber; company.persist(); + User user = currentUser.get(); eventService.record(company.id, ai.mnemosyne_systems.model.event.EventConstants.COMPANY_CREATED, company.id, - AuthHelper.findUser(auth).id, "Company created"); + user.id, "Company created"); applyEntitlements(company, entitlementIds, levelIds, entitlementDates, entitlementDurations, java.util.List.of()); return ReactRedirectSupport.redirect(client, "/companies"); @@ -121,18 +123,17 @@ public Response createCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("{id}") @Transactional - public Response updateCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, - @FormParam("address1") String address1, @FormParam("address2") String address2, - @FormParam("city") String city, @FormParam("state") String state, @FormParam("zip") String zip, - @FormParam("countryId") Long countryId, @FormParam("timezoneId") Long timezoneId, - @FormParam("userIds") java.util.List userIds, @FormParam("tamIds") java.util.List tamIds, + public Response updateCompany(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("name") String name, @FormParam("address1") String address1, + @FormParam("address2") String address2, @FormParam("city") String city, @FormParam("state") String state, + @FormParam("zip") String zip, @FormParam("countryId") Long countryId, + @FormParam("timezoneId") Long timezoneId, @FormParam("userIds") java.util.List userIds, + @FormParam("tamIds") java.util.List tamIds, @FormParam("entitlementIds") java.util.List entitlementIds, @FormParam("levelIds") java.util.List levelIds, @FormParam("entitlementDates") java.util.List entitlementDates, @FormParam("entitlementDurations") java.util.List entitlementDurations, @FormParam("phoneNumber") String phoneNumber, @Context HttpServletRequest request) { - requireAdmin(auth); Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -168,8 +169,9 @@ public Response updateCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, if (Ticket.count("companyEntitlement", entry) > 0) { continue; } + User user = currentUser.get(); eventService.record(entry.id, ai.mnemosyne_systems.model.event.EventConstants.COMPANY_ENTITLEMENT_DELETED, - company.id, AuthHelper.findUser(auth).id, "Company entitlement deleted"); + company.id, user.id, "Company entitlement deleted"); entry.delete(); } return ReactRedirectSupport.redirect(client, "/companies"); @@ -178,27 +180,18 @@ public Response updateCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("{id}/delete") @Transactional - public Response deleteCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + public Response deleteCompany(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); } + User user = currentUser.get(); eventService.record(company.id, ai.mnemosyne_systems.model.event.EventConstants.COMPANY_DELETED, company.id, - AuthHelper.findUser(auth).id, "Company deleted"); + user.id, "Company deleted"); company.delete(); return ReactRedirectSupport.redirect(client, "/companies"); } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - private java.util.List resolveUsers(java.util.List userIds, java.util.List tamIds, Long companyId) { java.util.LinkedHashSet ids = new java.util.LinkedHashSet<>(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementApiResource.java index c7148bc5..546b67f5 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementApiResource.java @@ -13,8 +13,9 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.util.AuthHelper; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.Path; @@ -27,12 +28,12 @@ @Path("/api/entitlements") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("admin") public class EntitlementApiResource { @GET @Transactional - public EntitlementListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public EntitlementListResponse list() { List entitlements = Entitlement .find("select distinct e from Entitlement e left join fetch e.supportLevels").list(); return new EntitlementListResponse("/entitlements/new", entitlements.stream().map(this::toListItem).toList()); @@ -41,17 +42,14 @@ public EntitlementListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String @GET @Path("/bootstrap") @Transactional - public EntitlementBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public EntitlementBootstrapResponse bootstrap() { return bootstrapResponse(); } @GET @Path("/{id}") @Transactional - public EntitlementDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireAdmin(auth); + public EntitlementDetailResponse detail(@PathParam("id") Long id) { Entitlement entitlement = Entitlement .find("select e from Entitlement e left join fetch e.supportLevels where e.id = ?1", id).firstResult(); if (entitlement == null) { @@ -127,14 +125,6 @@ private String hourLabel(Integer code) { return ""; } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record EntitlementListResponse(String createPath, List items) { } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementResource.java index 39002b76..d5d8057e 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/EntitlementResource.java @@ -13,11 +13,14 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -41,27 +44,28 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("admin") public class EntitlementResource { + @Inject + CurrentUser currentUser; + @jakarta.inject.Inject ai.mnemosyne_systems.service.EventService eventService; @GET - public Response listEntitlements(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response listEntitlements() { return Response.seeOther(URI.create("/entitlements")).build(); } @GET @Path("create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response createForm() { return Response.seeOther(URI.create("/entitlements/new")).build(); } @GET @Path("{id}") - public Response viewEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response viewEntitlement(@PathParam("id") Long id) { if (Entitlement.findById(id) == null) { throw new NotFoundException(); } @@ -70,8 +74,7 @@ public Response viewEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response editForm(@PathParam("id") Long id) { if (Entitlement.findById(id) == null) { throw new NotFoundException(); } @@ -81,12 +84,10 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @POST @Path("") @Transactional - public Response createEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, + public Response createEntitlement(@HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, @FormParam("description") String description, @FormParam("levelIds") List levelIds, @FormParam("versionIds") List versionIds, @FormParam("versionNames") List versionNames, @FormParam("versionDates") List versionDates) { - requireAdmin(auth); validate(name, description); Entitlement entitlement = new Entitlement(); entitlement.name = name.trim(); @@ -95,20 +96,20 @@ public Response createEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String au entitlement.versions.clear(); entitlement.versions.addAll(resolveVersions(entitlement, null, versionIds, versionNames, versionDates)); entitlement.persist(); + User user = currentUser.get(); eventService.record(entitlement.id, ai.mnemosyne_systems.model.event.EventConstants.ENTITLEMENT_CREATED, null, - AuthHelper.findUser(auth).id, "Entitlement created"); + user.id, "Entitlement created"); return ReactRedirectSupport.redirect(client, "/entitlements"); } @POST @Path("{id}") @Transactional - public Response updateEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, - @FormParam("description") String description, @FormParam("levelIds") List levelIds, - @FormParam("versionIds") List versionIds, @FormParam("versionNames") List versionNames, + public Response updateEntitlement(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("name") String name, @FormParam("description") String description, + @FormParam("levelIds") List levelIds, @FormParam("versionIds") List versionIds, + @FormParam("versionNames") List versionNames, @FormParam("versionDates") List versionDates) { - requireAdmin(auth); Entitlement entitlement = Entitlement .find("select e from Entitlement e left join fetch e.supportLevels where e.id = ?1", id).firstResult(); if (entitlement == null) { @@ -129,15 +130,14 @@ public Response updateEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String au @POST @Path("{id}/delete") @Transactional - public Response deleteEntitlement(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + public Response deleteEntitlement(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Entitlement entitlement = Entitlement.findById(id); if (entitlement == null) { throw new NotFoundException(); } + User user = currentUser.get(); eventService.record(entitlement.id, ai.mnemosyne_systems.model.event.EventConstants.ENTITLEMENT_DELETED, null, - AuthHelper.findUser(auth).id, "Entitlement deleted"); + user.id, "Entitlement deleted"); entitlement.delete(); return ReactRedirectSupport.redirect(client, "/entitlements"); } @@ -218,12 +218,4 @@ private Long parseVersionId(String value) { throw new BadRequestException("Version id is invalid"); } } - - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/HomeResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/HomeResource.java index 132d7af1..00a88a71 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/HomeResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/HomeResource.java @@ -10,8 +10,11 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; -import jakarta.ws.rs.CookieParam; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; + import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -22,11 +25,15 @@ @Path("/") @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class HomeResource { + @Inject + CurrentUser currentUser; + @GET - public Object index(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("error") String error) { - User user = AuthHelper.findUser(auth); + public Object index(@QueryParam("error") String error) { + User user = currentUser.get(); if (AuthHelper.isAdmin(user) || AuthHelper.isSupport(user) || AuthHelper.isSuperuser(user) || AuthHelper.isUser(user)) { return Response.status(Response.Status.SEE_OTHER).header("Location", "/").build(); @@ -37,8 +44,8 @@ public Object index(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryPara @GET @Path("/admin") - public Object adminHome(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = AuthHelper.findUser(auth); + public Object adminHome() { + User user = currentUser.get(); if (!AuthHelper.isAdmin(user)) { return Response.status(Response.Status.SEE_OTHER).header("Location", "/").build(); } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/LevelApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/LevelApiResource.java index e47f8d8e..5770bb11 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/LevelApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/LevelApiResource.java @@ -13,8 +13,11 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.Path; @@ -26,12 +29,15 @@ @Path("/api/levels") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("admin") public class LevelApiResource { + @Inject + CurrentUser currentUser; + @GET @Transactional - public LevelListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public LevelListResponse list() { List levels = Level.list("order by name"); return new LevelListResponse("/levels/new", levels.stream().map(this::toListItem).toList()); } @@ -39,16 +45,14 @@ public LevelListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) @GET @Path("/bootstrap") @Transactional - public LevelBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public LevelBootstrapResponse bootstrap() { return bootstrapResponse(); } @GET @Path("/{id}") @Transactional - public LevelDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public LevelDetailResponse detail(@PathParam("id") Long id) { Level level = Level.findById(id); if (level == null) { throw new jakarta.ws.rs.NotFoundException(); @@ -143,14 +147,6 @@ private String formatColorDisplay(String color) { return "(" + color + ")"; } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record LevelListResponse(String createPath, List items) { } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/LevelResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/LevelResource.java index 4d71bee4..433da67f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/LevelResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/LevelResource.java @@ -12,12 +12,14 @@ import ai.mnemosyne_systems.model.Level; import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; -import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -36,7 +38,11 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("admin") public class LevelResource { + @Inject + CurrentUser currentUser; + @jakarta.inject.Inject ai.mnemosyne_systems.service.EventService eventService; @@ -73,22 +79,19 @@ public String getDisplay() { new ColorOption("Teal"), new ColorOption("Aqua")); @GET - public Response listLevels(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response listLevels() { return Response.seeOther(URI.create("/levels")).build(); } @GET @Path("create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response createForm() { return Response.seeOther(URI.create("/levels/new")).build(); } @GET @Path("{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response editForm(@PathParam("id") Long id) { if (Level.findById(id) == null) { throw new NotFoundException(); } @@ -98,14 +101,12 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @POST @Path("") @Transactional - public Response createLevel(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, + public Response createLevel(@HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, @FormParam("description") String description, @FormParam("level") Integer levelValue, @FormParam("color") String color, @FormParam("fromDay") Integer fromDay, @FormParam("fromTime") Integer fromTime, @FormParam("toDay") Integer toDay, @FormParam("toTime") Integer toTime, @FormParam("countryId") Long countryId, @FormParam("timezoneId") Long timezoneId) { - requireAdmin(auth); Integer normalizedFromDay = normalizeDay(fromDay, Level.DayOption.MONDAY.getCode()); Integer normalizedFromTime = normalizeTime(fromTime, Level.HourOption.H00.getCode()); Integer normalizedToDay = normalizeDay(toDay, Level.DayOption.SUNDAY.getCode()); @@ -126,22 +127,21 @@ public Response createLevel(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, level.timezone = timezoneId != null ? Timezone.findById(timezoneId) : Timezone.find("name", "America/New_York").firstResult(); level.persist(); - eventService.record(level.id, ai.mnemosyne_systems.model.event.EventConstants.LEVEL_CREATED, null, - AuthHelper.findUser(auth).id, "Level created"); + User user = currentUser.get(); + eventService.record(level.id, ai.mnemosyne_systems.model.event.EventConstants.LEVEL_CREATED, null, user.id, + "Level created"); return ReactRedirectSupport.redirect(client, "/levels"); } @POST @Path("{id}") @Transactional - public Response updateLevel(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, - @FormParam("description") String description, @FormParam("level") Integer levelValue, - @FormParam("color") String color, @FormParam("fromDay") Integer fromDay, - @FormParam("fromTime") Integer fromTime, @FormParam("toDay") Integer toDay, - @FormParam("toTime") Integer toTime, @FormParam("countryId") Long countryId, - @FormParam("timezoneId") Long timezoneId) { - requireAdmin(auth); + public Response updateLevel(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("name") String name, @FormParam("description") String description, + @FormParam("level") Integer levelValue, @FormParam("color") String color, + @FormParam("fromDay") Integer fromDay, @FormParam("fromTime") Integer fromTime, + @FormParam("toDay") Integer toDay, @FormParam("toTime") Integer toTime, + @FormParam("countryId") Long countryId, @FormParam("timezoneId") Long timezoneId) { Level level = Level.findById(id); if (level == null) { throw new NotFoundException(); @@ -174,15 +174,14 @@ public Response updateLevel(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @POST @Path("{id}/delete") @Transactional - public Response deleteLevel(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + public Response deleteLevel(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Level level = Level.findById(id); if (level == null) { throw new NotFoundException(); } - eventService.record(level.id, ai.mnemosyne_systems.model.event.EventConstants.LEVEL_DELETED, null, - AuthHelper.findUser(auth).id, "Level deleted"); + User user = currentUser.get(); + eventService.record(level.id, ai.mnemosyne_systems.model.event.EventConstants.LEVEL_DELETED, null, user.id, + "Level deleted"); level.delete(); return ReactRedirectSupport.redirect(client, "/levels"); } @@ -254,12 +253,4 @@ private ColorOption resolveColorOption(String value) { } return null; } - - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/LogoutResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/LogoutResource.java index 42e9844d..724c7b4a 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/LogoutResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/LogoutResource.java @@ -6,10 +6,12 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ +// We don't need it anymore. package ai.mnemosyne_systems.resource; import ai.mnemosyne_systems.util.AuthHelper; import io.smallrye.common.annotation.Blocking; + import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java index f064a91c..9fa7575f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerApiResource.java @@ -16,8 +16,9 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -33,19 +34,18 @@ @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed("admin") public class OwnerApiResource { @GET @Transactional - public OwnerResponse owner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public OwnerResponse owner() { return toResponse(OwnerResource.findOwnerCompany()); } @POST @Transactional - public OwnerResponse update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, OwnerUpdateRequest request) { - requireAdmin(auth); + public OwnerResponse update(OwnerUpdateRequest request) { if (request == null || request.name() == null || request.name().isBlank()) { throw new WebApplicationException("Name is required", Response.Status.BAD_REQUEST); } @@ -89,14 +89,6 @@ public OwnerResponse update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, Ow return toResponse(company); } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - private OwnerResponse toResponse(Company company) { Installation installation = OwnerResource.findOrCreateInstallation(company); List supportUsers = company.users.stream() diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java index 665c58a8..ed75f002 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/OwnerResource.java @@ -16,9 +16,10 @@ import ai.mnemosyne_systems.infra.BrandingProvider; import ai.mnemosyne_systems.util.AuthHelper; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.transaction.Transactional; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; @@ -36,34 +37,31 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("admin") public class OwnerResource { @GET - public Response viewOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response viewOwner() { return Response.seeOther(URI.create("/owner")).build(); } @GET @Path("/edit") - public Response editOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response editOwner() { return Response.seeOther(URI.create("/owner/edit")).build(); } @POST @Path("/edit") @Transactional - public Response updateOwner(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @FormParam("name") String name, - @FormParam("address1") String address1, @FormParam("address2") String address2, - @FormParam("city") String city, @FormParam("state") String state, @FormParam("zip") String zip, - @FormParam("countryId") Long countryId, @FormParam("timezoneId") Long timezoneId, - @FormParam("phoneNumber") String phoneNumber, @FormParam("supportIds") List supportIds, - @FormParam("tamIds") List tamIds, @FormParam("headerFooterColor") String headerFooterColor, - @FormParam("headersColor") String headersColor, @FormParam("buttonsColor") String buttonsColor, - @FormParam("use24HourClock") Boolean use24HourClock, + public Response updateOwner(@FormParam("name") String name, @FormParam("address1") String address1, + @FormParam("address2") String address2, @FormParam("city") String city, @FormParam("state") String state, + @FormParam("zip") String zip, @FormParam("countryId") Long countryId, + @FormParam("timezoneId") Long timezoneId, @FormParam("phoneNumber") String phoneNumber, + @FormParam("supportIds") List supportIds, @FormParam("tamIds") List tamIds, + @FormParam("headerFooterColor") String headerFooterColor, @FormParam("headersColor") String headersColor, + @FormParam("buttonsColor") String buttonsColor, @FormParam("use24HourClock") Boolean use24HourClock, @FormParam("ticketAutoCloseDays") Integer ticketAutoCloseDays) { - requireAdmin(auth); Company company = findOwnerCompany(); if (name == null || name.isBlank()) { throw new jakarta.ws.rs.BadRequestException("Name is required"); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileApiResource.java index 1b232152..20a33d7c 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileApiResource.java @@ -14,10 +14,13 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.elytron.security.common.BcryptUtil; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.POST; @@ -30,20 +33,24 @@ @Path("/api/profile") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class ProfileApiResource { + @Inject + CurrentUser currentUser; + @GET @Transactional - public ProfileResponse profile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = requireUser(auth); + public ProfileResponse profile() { + User user = currentUser.get(); return toResponse(user); } @POST @Consumes(MediaType.APPLICATION_JSON) @Transactional - public ProfileResponse update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, ProfileUpdateRequest request) { - User user = requireUser(auth); + public ProfileResponse update(ProfileUpdateRequest request) { + User user = currentUser.get(); if (request == null || request.name() == null || request.name().isBlank()) { throw new WebApplicationException("Username is required", Response.Status.BAD_REQUEST); } @@ -73,9 +80,8 @@ public ProfileResponse update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path("/password") @Consumes(MediaType.APPLICATION_JSON) @Transactional - public PasswordResponse updatePassword(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - PasswordUpdateRequest request) { - User user = requireUser(auth); + public PasswordResponse updatePassword(PasswordUpdateRequest request) { + User user = currentUser.get(); if (request == null || request.oldPassword() == null || request.oldPassword().isBlank()) { throw new WebApplicationException("Old password is required", Response.Status.BAD_REQUEST); } @@ -179,14 +185,6 @@ private Integer normalizePageSize(Integer value) { return Math.min(value, PaginationSupport.MAX_PAGE_SIZE); } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record ProfileResponse(String role, String username, String displayName, String email, String fullName, String social, String phoneNumber, String phoneExtension, Long countryId, String countryName, Long timezoneId, String timezoneName, String logoBase64, String emailFormat, Integer pageSize, diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileResource.java index fccfd028..0c149afb 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ProfileResource.java @@ -14,12 +14,14 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.elytron.security.common.BcryptUtil; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -39,21 +41,24 @@ @Path("/profile") @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class ProfileResource { @Inject Logger logger; + @Inject + CurrentUser currentUser; + @GET - public Object edit(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Object edit() { return Response.seeOther(URI.create("/profile")).build(); } @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Transactional - public Object update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, MultivaluedMap form) { - User user = requireUser(auth); + public Object update(MultivaluedMap form) { + User user = currentUser.get(); String name = value(form, "name"); if (name == null || name.isBlank()) { return profileErrorRedirect("Username is required"); @@ -175,8 +180,7 @@ private String normalizeEmailFormat(String value) { @GET @Path("/password") - public Object passwordForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Object passwordForm() { return Response.seeOther(URI.create("/profile/password")).build(); } @@ -184,10 +188,9 @@ public Object passwordForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { @Path("/password") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Transactional - public Object updatePassword(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @FormParam("oldPassword") String oldPassword, @FormParam("newPassword") String newPassword, - @FormParam("confirmPassword") String confirmPassword) { - User user = requireUser(auth); + public Object updatePassword(@FormParam("oldPassword") String oldPassword, + @FormParam("newPassword") String newPassword, @FormParam("confirmPassword") String confirmPassword) { + User user = currentUser.get(); if (oldPassword == null || oldPassword.isBlank()) { return passwordErrorRedirect("Old password is required"); } @@ -212,14 +215,6 @@ private Response passwordErrorRedirect(String error) { return Response.seeOther(URI.create("/profile/password?error=" + encodeQueryValue(error))).build(); } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - private String value(MultivaluedMap form, String key) { if (form == null) { return null; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ReportApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ReportApiResource.java index 46d593c9..c9a9be8d 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ReportApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ReportApiResource.java @@ -8,24 +8,24 @@ package ai.mnemosyne_systems.resource; -import ai.mnemosyne_systems.model.Category; import ai.mnemosyne_systems.model.Company; import ai.mnemosyne_systems.model.Message; import ai.mnemosyne_systems.model.ReportData; import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.net.URI; import java.time.Duration; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -36,6 +36,7 @@ @Path("/api/reports") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({ "admin", "tam", "superuser" }) public class ReportApiResource { private static final String BUCKET_UNDER_1H = "< 1h"; private static final String BUCKET_1_TO_8H = "1–8h"; @@ -43,11 +44,13 @@ public class ReportApiResource { private static final String BUCKET_1_TO_7D = "1–7 days"; private static final String BUCKET_OVER_7D = "> 7 days"; + @Inject + CurrentUser currentUser; + @GET @Transactional - public ReportResponse reports(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period) { - User user = requireReporter(auth); + public ReportResponse reports(@QueryParam("companyId") Long companyId, @QueryParam("period") String period) { + User user = currentUser.get(); String safePeriod = period == null || period.isBlank() ? "all" : period.toLowerCase(); if (AuthHelper.isAdmin(user)) { List companies = Company.list( @@ -112,18 +115,6 @@ private List toHistogram(Map> histogram) { .toList(); } - private User requireReporter(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - if (AuthHelper.isAdmin(user) || User.TYPE_TAM.equalsIgnoreCase(user.type) - || User.TYPE_SUPERUSER.equalsIgnoreCase(user.type)) { - return user; - } - throw new WebApplicationException(Response.status(Response.Status.FORBIDDEN).location(URI.create("/")).build()); - } - private ReportData buildReportData(List filterCompanies, String period) { List tickets; if (filterCompanies != null && !filterCompanies.isEmpty()) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/ReportResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/ReportResource.java index 560c02f7..d48d8cc6 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/ReportResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/ReportResource.java @@ -11,7 +11,9 @@ import ai.mnemosyne_systems.model.*; import ai.mnemosyne_systems.service.PdfService; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; @@ -28,6 +30,7 @@ @Path("/reports") @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "tam", "superuser" }) public class ReportResource { private static final String BUCKET_UNDER_1H = "< 1h"; private static final String BUCKET_1_TO_8H = "1–8h"; @@ -38,26 +41,26 @@ public class ReportResource { @Inject PdfService pdfService; + @Inject + CurrentUser currentUser; + @GET - public Object adminReports(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period) { - requireAdmin(auth); + @RolesAllowed("admin") + public Object adminReports(@QueryParam("companyId") Long companyId, @QueryParam("period") String period) { return Response.seeOther(URI.create("/reports" + reportQuery(companyId, period))).build(); } @GET @Path("/tam") - public Object tamReports(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("companyId") Long companyId, - @QueryParam("period") String period) { - requireTam(auth); + @RolesAllowed("tam") + public Object tamReports(@QueryParam("companyId") Long companyId, @QueryParam("period") String period) { return Response.seeOther(URI.create("/reports" + reportQuery(companyId, period))).build(); } @GET @Path("/superuser") - public Object superuserReports(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period) { - requireSuperuser(auth); + @RolesAllowed("superuser") + public Object superuserReports(@QueryParam("companyId") Long companyId, @QueryParam("period") String period) { return Response.seeOther(URI.create("/reports" + reportQuery(companyId, period))).build(); } @@ -76,14 +79,13 @@ private String reportQuery(Long companyId, String period) { @Path("/export") @Produces("application/pdf") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response exportAdminReport(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period, + @RolesAllowed("admin") + public Response exportAdminReport(@QueryParam("companyId") Long companyId, @QueryParam("period") String period, @FormParam("statusChart") String statusChart, @FormParam("categoryChart") String categoryChart, @FormParam("companyChart") String companyChart, @FormParam("timeChart") String timeChart, @FormParam("responseTimeChart") String responseTimeChart, @FormParam("resolutionTimeChart") String resolutionTimeChart, @FormParam("histogramChart") String histogramChart) { - requireAdmin(auth); Company selectedCompany = companyId != null ? Company.findById(companyId) : null; String safePeriod = period == null || period.isBlank() ? "all" : period.toLowerCase(); ReportData data = buildReportData(selectedCompany != null ? List.of(selectedCompany) : null, safePeriod); @@ -99,14 +101,14 @@ public Response exportAdminReport(@CookieParam(AuthHelper.AUTH_COOKIE) String au @Path("/tam/export") @Produces("application/pdf") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response exportTamReport(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period, + @RolesAllowed("tam") + public Response exportTamReport(@QueryParam("companyId") Long companyId, @QueryParam("period") String period, @FormParam("statusChart") String statusChart, @FormParam("categoryChart") String categoryChart, @FormParam("companyChart") String companyChart, @FormParam("timeChart") String timeChart, @FormParam("responseTimeChart") String responseTimeChart, @FormParam("resolutionTimeChart") String resolutionTimeChart, @FormParam("histogramChart") String histogramChart) { - User user = requireTam(auth); + User user = currentUser.get(); List tamCompanies = Company.list( "select distinct c from Company c join c.users u where u = ?1 and exists (select t from Ticket t where t.company = c) order by c.name", user); @@ -129,14 +131,14 @@ public Response exportTamReport(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @Path("/superuser/export") @Produces("application/pdf") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response exportSuperuserReport(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("period") String period, + @RolesAllowed("superuser") + public Response exportSuperuserReport(@QueryParam("companyId") Long companyId, @QueryParam("period") String period, @FormParam("statusChart") String statusChart, @FormParam("categoryChart") String categoryChart, @FormParam("companyChart") String companyChart, @FormParam("timeChart") String timeChart, @FormParam("responseTimeChart") String responseTimeChart, @FormParam("resolutionTimeChart") String resolutionTimeChart, @FormParam("histogramChart") String histogramChart) { - User user = requireSuperuser(auth); + User user = currentUser.get(); List superuserCompanies = Company.list( "select distinct c from Company c join c.users u where u = ?1 and exists (select t from Ticket t where t.company = c) order by c.name", user); @@ -382,27 +384,4 @@ private Map buildChartImages(String statusChart, String category return images; } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - - private User requireTam(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null || !User.TYPE_TAM.equalsIgnoreCase(user.type)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - - private User requireSuperuser(String auth) { - User user = AuthHelper.findUser(auth); - if (user == null || !User.TYPE_SUPERUSER.equalsIgnoreCase(user.type)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/RssResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/RssResource.java index 5785f08a..e26c004e 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/RssResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/RssResource.java @@ -12,8 +12,11 @@ import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; -import jakarta.ws.rs.CookieParam; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; + import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -37,19 +40,17 @@ @Blocking public class RssResource { + @Inject + CurrentUser currentUser; + private static final DateTimeFormatter RFC_1123 = DateTimeFormatter.RFC_1123_DATE_TIME; @GET @Path("/support") @Produces("application/rss+xml") - public Response support(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Context UriInfo uriInfo) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - if (!AuthHelper.isSupport(user)) { - throw new ForbiddenException(); - } + @RolesAllowed("support") + public Response support(@Context UriInfo uriInfo) { + User user = currentUser.get(); List assigned = Ticket.find( "select distinct t from Ticket t join t.supportUsers u where u = ?1 and (t.status is null or lower(t.status) <> 'closed')", user).list(); @@ -65,14 +66,9 @@ public Response support(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Conte @GET @Path("/tam") @Produces("application/rss+xml") - public Response tam(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Context UriInfo uriInfo) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - if (!User.TYPE_TAM.equalsIgnoreCase(user.type)) { - throw new ForbiddenException(); - } + @RolesAllowed("tam") + public Response tam(@Context UriInfo uriInfo) { + User user = currentUser.get(); List scoped = Ticket.list( "select distinct t from Ticket t left join t.tamUsers tu left join t.company c left join c.users cu where tu = ?1 or cu = ?1", user); @@ -95,14 +91,9 @@ public Response tam(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Context U @GET @Path("/superuser") @Produces("application/rss+xml") - public Response superuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Context UriInfo uriInfo) { - User user = AuthHelper.findUser(auth); - if (user == null) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - if (!User.TYPE_SUPERUSER.equalsIgnoreCase(user.type)) { - throw new ForbiddenException(); - } + @RolesAllowed("superuser") + public Response superuser(@Context UriInfo uriInfo) { + User user = currentUser.get(); List scoped = SuperuserResource.loadScopedTickets(user); LinkedHashMap merged = new LinkedHashMap<>(); for (Ticket ticket : scoped) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserDirectoryApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserDirectoryApiResource.java index 62b3615a..9717e6a3 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserDirectoryApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserDirectoryApiResource.java @@ -14,8 +14,11 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; @@ -29,17 +32,19 @@ @Path("/api/superuser") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("superuser") public class SuperuserDirectoryApiResource { + @Inject + CurrentUser currentUser; + @GET @Path("/users") @Transactional - public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId) { - User currentUser = requireSuperuser(auth); + public UserDirectoryApiModels.DirectoryListResponse list(@QueryParam("companyId") Long companyId) { + User u = currentUser.get(); List companies = Company - .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", currentUser) - .list(); + .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", u).list(); Company selectedCompany = selectCompany(companies, companyId); List users = selectedCompany == null ? List.of() : Company. find("select u from Company c join c.users u where c = ?1 order by u.name", @@ -55,12 +60,11 @@ public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper @GET @Path("/users/bootstrap") @Transactional - public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("countryId") Long countryId) { - User currentUser = requireSuperuser(auth); + public UserDirectoryApiModels.UserFormResponse bootstrap(@QueryParam("companyId") Long companyId, + @QueryParam("countryId") Long countryId) { + User u = currentUser.get(); List companies = Company - .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", currentUser) - .list(); + .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", u).list(); Company selectedCompany = selectCompany(companies, companyId); if (selectedCompany == null) { throw new NotFoundException(); @@ -87,43 +91,39 @@ public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam(AuthHelper @GET @Path("/support-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse supportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User currentUser = requireSuperuser(auth); + public UserDirectoryApiModels.UserDetailResponse supportUser(@PathParam("id") Long id) { + User u = currentUser.get(); User user = userByType(id, User.TYPE_SUPPORT); - return detailResponse(currentUser, user, canViewSupportUser(currentUser, user)); + return detailResponse(u, user, canViewSupportUser(u, user)); } @GET @Path("/superuser-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse superuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User currentUser = requireSuperuser(auth); + public UserDirectoryApiModels.UserDetailResponse superuser(@PathParam("id") Long id) { + User u = currentUser.get(); User user = userByType(id, User.TYPE_SUPERUSER); - return detailResponse(currentUser, user, false); + return detailResponse(u, user, false); } @GET @Path("/user-profiles/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse profile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User currentUser = requireSuperuser(auth); + public UserDirectoryApiModels.UserDetailResponse profile(@PathParam("id") Long id) { + User u = currentUser.get(); User user = User.findById(id); if (user == null) { throw new NotFoundException(); } - return detailResponse(currentUser, user, false); + return detailResponse(u, user, false); } @GET @Path("/companies/{id}") @Transactional - public UserDirectoryApiModels.CompanyDetailResponse company(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User currentUser = requireSuperuser(auth); - Company company = allowedCompany(currentUser, id); + public UserDirectoryApiModels.CompanyDetailResponse company(@PathParam("id") Long id) { + User u = currentUser.get(); + Company company = allowedCompany(u, id); return new UserDirectoryApiModels.CompanyDetailResponse(company.id, company.name, company.address1, company.address2, company.city, company.state, company.zip, company.phoneNumber, company.country == null ? null : company.country.name, diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java index 6bb7c9ce..7de6bb9e 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserResource.java @@ -24,13 +24,15 @@ import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.TicketTimeSupport; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.elytron.security.common.BcryptUtil; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -58,11 +60,15 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("superuser") public class SuperuserResource { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMMM d yyyy, h.mma", Locale.ENGLISH); + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @@ -74,32 +80,28 @@ public class SuperuserResource { @GET @Path("superuser") - public Response home(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response home() { return Response.seeOther(URI.create("/superuser/tickets")).build(); } @GET @Path("superuser/users") - public Response usersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response usersRoot() { return Response.seeOther(URI.create("/superuser/users")).build(); } @GET @Path("superuser/users/{companyId}") - public Response listUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - User user = requireSuperuser(auth); + public Response listUsers(@PathParam("companyId") Long companyId) { + User user = currentUser.get(); Company company = allowedCompany(user, companyId); return Response.seeOther(URI.create("/superuser/users?companyId=" + company.id)).build(); } @GET @Path("superuser/users/{companyId}/create") - public Response createUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - User user = requireSuperuser(auth); + public Response createUserForm(@PathParam("companyId") Long companyId) { + User user = currentUser.get(); Company company = allowedCompany(user, companyId); return Response.seeOther(URI.create("/superuser/users/new?companyId=" + company.id)).build(); } @@ -107,13 +109,13 @@ public Response createUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("superuser/users") @Transactional - public Response createUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @FormParam("name") String name, - @FormParam("fullName") String fullName, @FormParam("email") String email, - @FormParam("social") String social, @FormParam("phoneNumber") String phoneNumber, - @FormParam("phoneExtension") String phoneExtension, @FormParam("timezoneId") Long timezoneId, - @FormParam("countryId") Long countryId, @FormParam("password") String password, - @FormParam("type") String type, @FormParam("companyId") Long companyId) { - User user = requireSuperuser(auth); + public Response createUser(@FormParam("name") String name, @FormParam("fullName") String fullName, + @FormParam("email") String email, @FormParam("social") String social, + @FormParam("phoneNumber") String phoneNumber, @FormParam("phoneExtension") String phoneExtension, + @FormParam("timezoneId") Long timezoneId, @FormParam("countryId") Long countryId, + @FormParam("password") String password, @FormParam("type") String type, + @FormParam("companyId") Long companyId) { + User user = currentUser.get(); if (name == null || name.isBlank()) { throw new BadRequestException("Username is required"); } @@ -151,29 +153,25 @@ public Response createUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Fo @GET @Path("superuser/tickets") - public Response tickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response tickets() { return Response.seeOther(URI.create("/superuser/tickets")).build(); } @GET @Path("superuser/tickets/open") - public Response openTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response openTickets() { return Response.seeOther(URI.create("/superuser/tickets/open")).build(); } @GET @Path("superuser/tickets/closed") - public Response closedTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response closedTickets() { return Response.seeOther(URI.create("/superuser/tickets/closed")).build(); } @GET @Path("superuser/tickets/create") - public Response createTicketForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSuperuser(auth); + public Response createTicketForm() { return Response.seeOther(URI.create("/superuser/tickets/new")).build(); } @@ -181,9 +179,8 @@ public Response createTicketForm(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @Path("superuser/tickets") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response createTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - User user = requireSuperuser(auth); + public Response createTicket(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { + User user = currentUser.get(); String status = AttachmentHelper.readFormValue(input, "status"); String title = Ticket.normalizeTitle(AttachmentHelper.readFormValue(input, "title")); String messageBody = AttachmentHelper.readFormValue(input, "message"); @@ -243,8 +240,7 @@ public Response createTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("superuser/support-users/{id}") - public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSuperuser(auth); + public Response viewSupportUser(@PathParam("id") Long id) { User supportUser = User.findById(id); if (supportUser == null || !User.TYPE_SUPPORT.equalsIgnoreCase(supportUser.type)) { throw new NotFoundException(); @@ -254,8 +250,8 @@ public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("superuser/superuser-users/{id}") - public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public Response viewSuperuser(@PathParam("id") Long id) { + User user = currentUser.get(); User viewedUser = User.findById(id); if (viewedUser == null || !User.TYPE_SUPERUSER.equalsIgnoreCase(viewedUser.type)) { throw new NotFoundException(); @@ -271,8 +267,8 @@ public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("superuser/user-profiles/{id}") - public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public Response viewUserProfile(@PathParam("id") Long id) { + User user = currentUser.get(); User viewedUser = User.findById(id); if (viewedUser == null) { throw new NotFoundException(); @@ -288,16 +284,16 @@ public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("superuser/companies/{id}") - public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public Response viewCompany(@PathParam("id") Long id) { + User user = currentUser.get(); Company company = allowedCompany(user, id); return Response.seeOther(URI.create("/superuser/companies/" + company.id)).build(); } @GET @Path("superuser/tickets/{id}") - public Response ticketDetail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public Response ticketDetail(@PathParam("id") Long id) { + User user = currentUser.get(); if (findTicketForSuperuser(user, id) == null) { throw new NotFoundException(); } @@ -308,9 +304,8 @@ public Response ticketDetail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @ @Path("superuser/tickets/{id}/messages") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response addMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - MultipartFormDataInput input) { - User user = requireSuperuser(auth); + public Response addMessage(@PathParam("id") Long id, MultipartFormDataInput input) { + User user = currentUser.get(); String body = AttachmentHelper.readFormValue(input, "body"); if (body == null || body.isBlank()) { throw new BadRequestException("Message is required"); @@ -338,8 +333,8 @@ public Response addMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Pa @GET @Path("superuser/tickets/{id}/edit") - public Response ticketEdit(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public Response ticketEdit(@PathParam("id") Long id) { + User user = currentUser.get(); if (findTicketForSuperuser(user, id) == null) { throw new NotFoundException(); } @@ -349,11 +344,10 @@ public Response ticketEdit(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Pa @POST @Path("superuser/tickets/{id}") @Transactional - public Response updateTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id, + public Response updateTicket(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id, @FormParam("title") String title, @FormParam("affectsVersionId") Long affectsVersionId, @FormParam("resolvedVersionId") Long resolvedVersionId) { - User user = requireSuperuser(auth); + User user = currentUser.get(); String normalizedTitle = Ticket.normalizeTitle(title); Ticket ticket = findTicketForSuperuser(user, id); if (ticket == null) { @@ -809,14 +803,6 @@ String formatDate(LocalDateTime date) { return formatted.replace("AM", "am").replace("PM", "pm"); } - private User requireSuperuser(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSuperuser(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - static class SupportTicketData { List assignedTickets; List openTickets; diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java index c01783b5..1537ea4f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SuperuserTicketApiResource.java @@ -20,9 +20,11 @@ import ai.mnemosyne_systems.service.CrossReferenceService; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; @@ -41,8 +43,12 @@ @Path("/api/superuser/tickets") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("superuser") public class SuperuserTicketApiResource { + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @@ -54,11 +60,11 @@ public class SuperuserTicketApiResource { @GET @Transactional - public SupportTicketApiResource.SupportTicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, + public SupportTicketApiResource.SupportTicketListResponse list( @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, @QueryParam("dir") String dir) { - User user = requireSuperuser(auth); + User user = currentUser.get(); SuperuserResource.SupportTicketData data = superuserResource.buildTicketDataForUser(user); String normalizedView = normalizeView(view); List tickets = switch (normalizedView) { @@ -91,10 +97,10 @@ public SupportTicketApiResource.SupportTicketListResponse list(@CookieParam(Auth @GET @Path("/suggest") @Transactional - public SupportTicketApiResource.TicketSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, + public SupportTicketApiResource.TicketSuggestionResponse suggest( @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, @QueryParam("exclude") Long exclude) { - User user = requireSuperuser(auth); + User user = currentUser.get(); SuperuserResource.SupportTicketData data = superuserResource.buildTicketDataForUser(user); List tickets = TicketSearchSupport.combineTickets(data.assignedTickets, data.openTickets, data.closedTickets); @@ -110,10 +116,9 @@ public SupportTicketApiResource.TicketSuggestionResponse suggest(@CookieParam(Au @GET @Path("/bootstrap") @Transactional - public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap( - @CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("companyId") Long companyId, + public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, @QueryParam("companyEntitlementId") Long companyEntitlementId) { - User user = requireSuperuser(auth); + User user = currentUser.get(); SuperuserResource.SupportTicketData data = superuserResource.buildTicketDataForUser(user); List companies = superuserResource.userCompanies(user); Company selectedCompany = selectCompany(companies, companyId); @@ -150,9 +155,8 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}") @Transactional - public UserTicketApiResource.RoleTicketDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User user = requireSuperuser(auth); + public UserTicketApiResource.RoleTicketDetailResponse detail(@PathParam("id") Long id) { + User user = currentUser.get(); Ticket ticket = superuserResource.findTicketForSuperuser(user, id); if (ticket == null) { throw new NotFoundException(); @@ -212,9 +216,7 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}/references") @Transactional - public CrossReferenceService.CrossReferencesResponse references(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSuperuser(auth); + public CrossReferenceService.CrossReferencesResponse references(@PathParam("id") Long id) { Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java index 71be4299..fb716431 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportResource.java @@ -23,15 +23,16 @@ import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.util.AttachmentHelper; -import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.elytron.security.common.BcryptUtil; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -56,7 +57,12 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed("support") public class SupportResource { + + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @@ -66,29 +72,25 @@ public class SupportResource { EventService eventService; @GET - public Response listTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + public Response listTickets() { return Response.seeOther(URI.create("/support/tickets")).build(); } @GET @Path("/open") - public Response listOpenTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + public Response listOpenTickets() { return Response.seeOther(URI.create("/support/tickets/open")).build(); } @GET @Path("/closed") - public Response listClosedTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + public Response listClosedTickets() { return Response.seeOther(URI.create("/support/tickets/closed")).build(); } @GET @Path("/support-users/{id}") - public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + public Response viewSupportUser(@PathParam("id") Long id) { User supportUser = User.findById(id); if (supportUser == null || !User.TYPE_SUPPORT.equalsIgnoreCase(supportUser.type)) { throw new NotFoundException(); @@ -98,8 +100,7 @@ public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("/tam-users/{id}") - public Response viewTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + public Response viewTamUser(@PathParam("id") Long id) { User tamUser = User.findById(id); if (tamUser == null || !User.TYPE_TAM.equalsIgnoreCase(tamUser.type)) { throw new NotFoundException(); @@ -109,8 +110,7 @@ public Response viewTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @GET @Path("/superuser-users/{id}") - public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + public Response viewSuperuser(@PathParam("id") Long id) { User superuser = User.findById(id); if (superuser == null || !User.TYPE_SUPERUSER.equalsIgnoreCase(superuser.type)) { throw new NotFoundException(); @@ -120,8 +120,7 @@ public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("/user-profiles/{id}") - public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + public Response viewUserProfile(@PathParam("id") Long id) { User viewedUser = User.findById(id); if (viewedUser == null) { throw new NotFoundException(); @@ -131,8 +130,7 @@ public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("/companies/{id}") - public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + public Response viewCompany(@PathParam("id") Long id) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -142,16 +140,13 @@ public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @GET @Path("/users") - public Response supportUsersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + public Response supportUsersRoot() { return Response.seeOther(URI.create("/support/users")).build(); } @GET @Path("/users/{companyId}") - public Response listSupportUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - requireSupport(auth); + public Response listSupportUsers(@PathParam("companyId") Long companyId) { List companies = Company.list("order by name"); Company selectedCompany = null; if (companyId != null) { @@ -168,9 +163,7 @@ public Response listSupportUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @GET @Path("/users/{companyId}/create") - public Response createSupportUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - requireSupport(auth); + public Response createSupportUserForm(@PathParam("companyId") Long companyId) { Company selectedCompany = Company.findById(companyId); if (selectedCompany == null) { throw new NotFoundException(); @@ -181,14 +174,12 @@ public Response createSupportUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) Strin @POST @Path("/users") @Transactional - public Response createSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @FormParam("name") String name, - @FormParam("fullName") String fullName, @FormParam("email") String email, - @FormParam("social") String social, @FormParam("phoneNumber") String phoneNumber, - @FormParam("phoneExtension") String phoneExtension, @FormParam("timezoneId") Long timezoneId, - @FormParam("countryId") Long countryId, @FormParam("password") String password, - @FormParam("type") String type, @FormParam("companyId") Long companyId, - @Context HttpServletRequest request) { - requireSupport(auth); + public Response createSupportUser(@FormParam("name") String name, @FormParam("fullName") String fullName, + @FormParam("email") String email, @FormParam("social") String social, + @FormParam("phoneNumber") String phoneNumber, @FormParam("phoneExtension") String phoneExtension, + @FormParam("timezoneId") Long timezoneId, @FormParam("countryId") Long countryId, + @FormParam("password") String password, @FormParam("type") String type, + @FormParam("companyId") Long companyId, @Context HttpServletRequest request) { if (name == null || name.isBlank()) { throw new BadRequestException("Username is required"); } @@ -224,8 +215,9 @@ public Response createSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String au newUser.passwordHash = BcryptUtil.bcryptHash(password); } newUser.persist(); + User user = currentUser.get(); eventService.record(newUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_CREATED, company.id, - AuthHelper.findUser(auth).id, "User created"); + user.id, "User created"); boolean exists = company.users.stream() .anyMatch(existing -> existing.id != null && existing.id.equals(newUser.id)); if (!exists) { @@ -243,20 +235,17 @@ private String trimOrNull(String value) { @GET @Path("/tickets/create") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + public Response createForm() { return Response.seeOther(URI.create("/support/tickets/new")).build(); } @GET @Path("/tickets/{id}") - public Response ticketDetail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @jakarta.ws.rs.PathParam("id") Long id) { + public Response ticketDetail(@jakarta.ws.rs.PathParam("id") Long id) { Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); } - requireSupport(auth); return Response.seeOther(URI.create("/support/tickets/" + id)).build(); } @@ -268,9 +257,8 @@ private List loadMessages(Ticket ticket, User viewer) { @Path("/tickets/{id}/messages") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response addMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @jakarta.ws.rs.PathParam("id") Long id, - MultipartFormDataInput input) { - User user = requireSupport(auth); + public Response addMessage(@jakarta.ws.rs.PathParam("id") Long id, MultipartFormDataInput input) { + User user = currentUser.get(); String body = AttachmentHelper.readFormValue(input, "body"); if (body == null || body.isBlank()) { throw new BadRequestException("Message is required"); @@ -312,9 +300,8 @@ public Response addMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @ja @Path("/tickets") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response createTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - User user = requireSupport(auth); + public Response createTicket(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { + User user = currentUser.get(); String status = AttachmentHelper.readFormValue(input, "status"); String title = Ticket.normalizeTitle(AttachmentHelper.readFormValue(input, "title")); String messageBody = AttachmentHelper.readFormValue(input, "message"); @@ -380,14 +367,14 @@ public Response createTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("/tickets/{id}") @Transactional - public Response updateTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @jakarta.ws.rs.PathParam("id") Long id, - @FormParam("title") String title, @FormParam("status") String status, - @FormParam("companyId") Long companyId, @FormParam("companyEntitlementId") Long companyEntitlementId, - @FormParam("categoryId") Long categoryId, @FormParam("externalIssueLink") String externalIssueLink, + public Response updateTicket(@HeaderParam("X-Billetsys-Client") String client, + @jakarta.ws.rs.PathParam("id") Long id, @FormParam("title") String title, + @FormParam("status") String status, @FormParam("companyId") Long companyId, + @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId, + @FormParam("externalIssueLink") String externalIssueLink, @FormParam("affectsVersionId") Long affectsVersionId, @FormParam("resolvedVersionId") Long resolvedVersionId) { - User user = requireSupport(auth); + User user = currentUser.get(); String normalizedTitle = Ticket.normalizeTitle(title); Ticket ticket = Ticket.findById(id); if (ticket == null) { @@ -450,9 +437,9 @@ public Response updateTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("/tickets/{id}/assign") @Transactional - public Response assignTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @jakarta.ws.rs.PathParam("id") Long id) { - User user = requireSupport(auth); + public Response assignTicket(@HeaderParam("X-Billetsys-Client") String client, + @jakarta.ws.rs.PathParam("id") Long id) { + User user = currentUser.get(); Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); @@ -501,9 +488,7 @@ private void assignCompanyTams(Ticket ticket) { @GET @Path("/tickets/company/{id}/entitlements") - public Response companyEntitlements(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @jakarta.ws.rs.PathParam("id") Long id, @QueryParam("message") String message) { - requireSupport(auth); + public Response companyEntitlements(@jakarta.ws.rs.PathParam("id") Long id, @QueryParam("message") String message) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -595,11 +580,4 @@ static class SupportTicketCounts { } } - private User requireSupport(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java index c97cbcfc..00156a5f 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportTicketApiResource.java @@ -20,9 +20,11 @@ import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.model.event.Event; import ai.mnemosyne_systems.service.EventService; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.HEAD; @@ -43,8 +45,12 @@ @Path("/api/support/tickets") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("support") public class SupportTicketApiResource { + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @Inject @@ -52,11 +58,10 @@ public class SupportTicketApiResource { @GET @Transactional - public SupportTicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, - @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, - @QueryParam("dir") String dir) { - User user = requireSupport(auth); + public SupportTicketListResponse list(@QueryParam("view") @DefaultValue("assigned") String view, + @QueryParam("q") String q, @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, + @QueryParam("sort") String sort, @QueryParam("dir") String dir) { + User user = currentUser.get(); SupportTicketViewSupport.SupportTicketData data = SupportTicketViewSupport.buildTicketData(user); SupportTicketViewSupport.SupportTicketCounts counts = SupportTicketViewSupport.loadTicketCounts(user); String normalizedView = normalizeView(view); @@ -89,10 +94,9 @@ public SupportTicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) Strin @GET @Path("/suggest") @Transactional - public TicketSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, - @QueryParam("exclude") Long exclude) { - User user = requireSupport(auth); + public TicketSuggestionResponse suggest(@QueryParam("view") @DefaultValue("assigned") String view, + @QueryParam("q") String q, @QueryParam("exclude") Long exclude) { + User user = currentUser.get(); SupportTicketViewSupport.SupportTicketData data = SupportTicketViewSupport.buildTicketData(user); String normalizedView = normalizeView(view); List tickets = TicketSearchSupport.combineTickets(data.assignedTickets(), data.openTickets(), @@ -108,9 +112,9 @@ public TicketSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) Str @GET @Path("/bootstrap") @Transactional - public SupportTicketBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("companyEntitlementId") Long companyEntitlementId) { - User user = requireSupport(auth); + public SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, + @QueryParam("companyEntitlementId") Long companyEntitlementId) { + User user = currentUser.get(); SupportTicketViewSupport.SupportTicketCounts counts = SupportTicketViewSupport.loadTicketCounts(user); List companies = Company.list("order by name"); Company selectedCompany = selectCompany(companies, companyId); @@ -142,9 +146,8 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}") @Transactional - public SupportTicketDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User user = requireSupport(auth); + public SupportTicketDetailResponse detail(@PathParam("id") Long id) { + User user = currentUser.get(); SupportTicketViewSupport.SupportTicketCounts counts = SupportTicketViewSupport.loadTicketCounts(user); Ticket ticket = Ticket.findById(id); if (ticket == null) { @@ -200,9 +203,7 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}/references") @Transactional - public CrossReferenceService.CrossReferencesResponse references(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public CrossReferenceService.CrossReferencesResponse references(@PathParam("id") Long id) { Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); @@ -391,14 +392,6 @@ private String userPath(User user) { }; } - private User requireSupport(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record SupportTicketListResponse(String view, String title, int assignedCount, int openCount, String createPath, String searchTerm, List items, int page, int pageSize, int totalItems, int totalPages) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportUserApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportUserApiResource.java index fd8b85ec..1934b937 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/SupportUserApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/SupportUserApiResource.java @@ -13,8 +13,11 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; @@ -28,14 +31,17 @@ @Path("/api/support") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("support") public class SupportUserApiResource { + @Inject + CurrentUser cUser; + @GET @Path("/users") @Transactional - public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId) { - User currentUser = requireSupport(auth); + public UserDirectoryApiModels.DirectoryListResponse list(@QueryParam("companyId") Long companyId) { + User currentUser = cUser.get(); SupportResource.SupportTicketCounts counts = SupportResource.loadTicketCounts(currentUser); List companies = Company.list("order by name"); Company selectedCompany = selectCompany(companies, companyId); @@ -53,9 +59,9 @@ public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper @GET @Path("/users/bootstrap") @Transactional - public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("countryId") Long countryId) { - User currentUser = requireSupport(auth); + public UserDirectoryApiModels.UserFormResponse bootstrap(@QueryParam("companyId") Long companyId, + @QueryParam("countryId") Long countryId) { + User currentUser = cUser.get(); SupportResource.loadTicketCounts(currentUser); List companies = Company.list("order by name"); Company selectedCompany = companyId == null ? (companies.isEmpty() ? null : companies.get(0)) @@ -86,45 +92,35 @@ public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam(AuthHelper @GET @Path("/support-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse supportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public UserDirectoryApiModels.UserDetailResponse supportUser(@PathParam("id") Long id) { return profileDetail(id, User.TYPE_SUPPORT, "/support/users"); } @GET @Path("/tam-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse tamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public UserDirectoryApiModels.UserDetailResponse tamUser(@PathParam("id") Long id) { return profileDetail(id, User.TYPE_TAM, "/support/users"); } @GET @Path("/superuser-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse superuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public UserDirectoryApiModels.UserDetailResponse superuser(@PathParam("id") Long id) { return profileDetail(id, User.TYPE_SUPERUSER, "/support/users"); } @GET @Path("/user-profiles/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse userProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public UserDirectoryApiModels.UserDetailResponse userProfile(@PathParam("id") Long id) { return profileDetail(id, null, "/support/users"); } @GET @Path("/companies/{id}") @Transactional - public UserDirectoryApiModels.CompanyDetailResponse company(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireSupport(auth); + public UserDirectoryApiModels.CompanyDetailResponse company(@PathParam("id") Long id) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -200,11 +196,4 @@ private Country selectCountry(User user, Long countryId) { return Country.find("code", "US").firstResult(); } - private User requireSupport(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TamUserApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TamUserApiResource.java index 49b388b2..9f44cfa6 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TamUserApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TamUserApiResource.java @@ -13,8 +13,11 @@ import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; @@ -27,13 +30,16 @@ @Path("/api/tam/users") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("tam") public class TamUserApiResource { + @Inject + CurrentUser cUser; + @GET @Transactional - public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId) { - User currentUser = requireTam(auth); + public UserDirectoryApiModels.DirectoryListResponse list(@QueryParam("companyId") Long companyId) { + User currentUser = cUser.get(); List companies = Company .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", currentUser) .list(); @@ -54,9 +60,9 @@ public UserDirectoryApiModels.DirectoryListResponse list(@CookieParam(AuthHelper @GET @Path("/bootstrap") @Transactional - public UserDirectoryApiModels.UserFormResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId, @QueryParam("countryId") Long countryId) { - User currentUser = requireTam(auth); + public UserDirectoryApiModels.UserFormResponse bootstrap(@QueryParam("companyId") Long companyId, + @QueryParam("countryId") Long countryId) { + User currentUser = cUser.get(); List companies = Company .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", currentUser) .list(); @@ -103,12 +109,4 @@ private Country selectCountry(Long countryId) { } return Country.find("code", "US").firstResult(); } - - private User requireTam(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isUser(user) || !User.TYPE_TAM.equalsIgnoreCase(user.type)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketImportApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketImportApiResource.java index 2cb405aa..10a56dde 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketImportApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketImportApiResource.java @@ -13,11 +13,13 @@ import ai.mnemosyne_systems.service.TicketImportService; import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -32,15 +34,18 @@ @Path("/api/ticket-imports") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed({ "admin", "support" }) public class TicketImportApiResource { + @Inject + CurrentUser currentUser; + @Inject TicketImportService ticketImportService; @GET @Path("/bootstrap") - public TicketImportBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireImportAccess(auth); + public TicketImportBootstrapResponse bootstrap() { return new TicketImportBootstrapResponse(List.of(new TicketImportFormat("csv", "CSV", CsvTicketImportSource.REQUIRED_COLUMNS, CsvTicketImportSource.OPTIONAL_COLUMNS)), "/api/ticket-imports/csv"); @@ -49,9 +54,8 @@ public TicketImportBootstrapResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOK @POST @Path("/csv") @Consumes(MediaType.MULTIPART_FORM_DATA) - public TicketImportService.TicketImportSummary importCsv(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - MultipartFormDataInput input) { - User actor = requireImportAccess(auth); + public TicketImportService.TicketImportSummary importCsv(MultipartFormDataInput input) { + User actor = currentUser.get(); AttachmentHelper.UploadedFile file = AttachmentHelper.readFile(input, "file", CsvTicketImportSource.MAX_CSV_BYTES); if (file == null || file.data() == null || file.data().length == 0) { @@ -65,14 +69,6 @@ public TicketImportService.TicketImportSummary importCsv(@CookieParam(AuthHelper } } - private User requireImportAccess(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user) && !AuthHelper.isAdmin(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - public record TicketImportBootstrapResponse(List formats, String submitPath) { } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java index 73ee9601..28f825af 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketRatingApiResource.java @@ -10,12 +10,13 @@ import ai.mnemosyne_systems.model.Ticket; import ai.mnemosyne_systems.model.User; -import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.NotFoundException; @@ -36,17 +37,17 @@ public class TicketRatingApiResource { private static final int MAX_COMMENT_LENGTH = 2000; + @Inject + CurrentUser currentUser; + @POST @Path("/{id}/rating") @Transactional - public Response submitRating(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id, + @RolesAllowed({ "user", "superuser" }) + public Response submitRating(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id, @FormParam("rating") Integer rating, @FormParam("ratingComment") String ratingComment) { - User user = AuthHelper.findUser(auth); - if (user == null || (!User.TYPE_USER.equalsIgnoreCase(user.type) && !AuthHelper.isSuperuser(user))) { - throw new WebApplicationException(Response.seeOther(URI.create("/login")).build()); - } + User user = currentUser.get(); Ticket ticket = Ticket.findById(id); if (ticket == null) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java index d26cb1d2..9364ddd7 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketResource.java @@ -19,12 +19,14 @@ import ai.mnemosyne_systems.service.PdfService; import ai.mnemosyne_systems.service.TicketEmailService; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -59,6 +61,7 @@ @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.TEXT_HTML) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class TicketResource { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMMM d yyyy, h.mma", @@ -66,6 +69,9 @@ public class TicketResource { private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(Duration.ofSeconds(10)).build(); + @Inject + CurrentUser currentUser; + @Inject SupportResource supportResource; @@ -85,22 +91,22 @@ public class TicketResource { EventService eventService; @GET - public Response list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + @RolesAllowed("support") + public Response list() { return Response.seeOther(URI.create("/tickets")).build(); } @GET @Path("/new") - public Response createForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireSupport(auth); + @RolesAllowed("support") + public Response createForm() { return Response.seeOther(URI.create("/tickets/new")).build(); } @GET @Path("/{id}/edit") - public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + @RolesAllowed("support") + public Response editForm(@PathParam("id") Long id) { Ticket ticket = Ticket.find( "select t from Ticket t left join fetch t.companyEntitlement ce left join fetch ce.entitlement left join fetch ce.supportLevel where t.id = ?1", id).firstResult(); @@ -112,16 +118,16 @@ public Response editForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @GET @Path("/{id}") - public Object viewTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = AuthHelper.findUser(auth); + public Object viewTicket(@PathParam("id") Long id) { + User user = currentUser.get(); if (AuthHelper.isSupport(user)) { - return supportResource.ticketDetail(auth, id); + return supportResource.ticketDetail(id); } if (AuthHelper.isSuperuser(user)) { - return superuserResource.ticketDetail(auth, id); + return superuserResource.ticketDetail(id); } if (AuthHelper.isUser(user)) { - return userResource.ticketDetail(auth, id); + return userResource.ticketDetail(id); } return Response.seeOther(URI.create("/")).build(); } @@ -129,16 +135,16 @@ public Object viewTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Path @GET @Path("/alarm/status") @Produces(MediaType.TEXT_PLAIN) - public Response alarmStatus(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = AuthHelper.findUser(auth); + public Response alarmStatus() { + User user = currentUser.get(); boolean alarm = hasAlarm(user); return Response.ok(Boolean.toString(alarm)).build(); } @GET @Path("/external-preview") - public Response externalPreview(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("url") String url) { - requireSupport(auth); + @RolesAllowed("support") + public Response externalPreview(@QueryParam("url") String url) { if (url == null || url.isBlank()) { throw new BadRequestException("URL is required"); } @@ -158,11 +164,11 @@ public Response externalPreview(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @POST @Transactional - public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("status") String status, + @RolesAllowed("support") + public Response create(@HeaderParam("X-Billetsys-Client") String client, @FormParam("status") String status, @FormParam("title") String title, @FormParam("companyId") Long companyId, @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId) { - User user = requireSupport(auth); + User user = currentUser.get(); String normalizedTitle = Ticket.normalizeTitle(title); if (status == null || status.isBlank()) { throw new BadRequestException("Status is required"); @@ -203,13 +209,14 @@ public Response create(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("/{id}") @Transactional - public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("status") String status, - @FormParam("companyId") Long companyId, @FormParam("companyEntitlementId") Long companyEntitlementId, - @FormParam("categoryId") Long categoryId, @FormParam("externalIssueLink") String externalIssueLink, + @RolesAllowed("support") + public Response update(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("status") String status, @FormParam("companyId") Long companyId, + @FormParam("companyEntitlementId") Long companyEntitlementId, @FormParam("categoryId") Long categoryId, + @FormParam("externalIssueLink") String externalIssueLink, @FormParam("affectsVersionId") Long affectsVersionId, @FormParam("resolvedVersionId") Long resolvedVersionId) { - User user = requireSupport(auth); + User user = currentUser.get(); Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); @@ -261,13 +268,13 @@ public Response update(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathPa @POST @Path("/{id}/delete") @Transactional - public Response delete(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - User user = requireSupport(auth); + @RolesAllowed("support") + public Response delete(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { Ticket ticket = Ticket.findById(id); if (ticket == null) { throw new NotFoundException(); } + User user = currentUser.get(); eventService.recordTicketDeleted(ticket, user); ticket.delete(); return ReactRedirectSupport.redirect(client, "/tickets"); @@ -275,8 +282,8 @@ public Response delete(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("/company/{id}/entitlements") - public Response companyEntitlements(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireSupport(auth); + @RolesAllowed("support") + public Response companyEntitlements(@PathParam("id") Long id) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -325,8 +332,8 @@ private Version resolveVersion(CompanyEntitlement companyEntitlement, Long versi @GET @Path("/export/{id}") @Produces("application/pdf") - public Response exportTicketToPdf(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = AuthHelper.findUser(auth); + public Response exportTicketToPdf(@PathParam("id") Long id) { + User user = currentUser.get(); if (!AuthHelper.isSupport(user) && !AuthHelper.isUser(user) && !AuthHelper.isSuperuser(user)) { throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); } @@ -343,14 +350,6 @@ public Response exportTicketToPdf(@CookieParam(AuthHelper.AUTH_COOKIE) String au .build(); } - private User requireSupport(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - private String fetchExternalPreviewHtml(URI previewUri) { HttpRequest request = HttpRequest.newBuilder(previewUri).timeout(Duration.ofSeconds(15)) .header("User-Agent", "billetsys-preview").GET().build(); diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java index 9b9be76f..67de73f6 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TicketWorkbenchApiResource.java @@ -16,9 +16,12 @@ import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; + import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.Path; @@ -39,15 +42,19 @@ @Path("/api/ticket-workbench") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed("support") public class TicketWorkbenchApiResource { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMMM d yyyy, h.mma", Locale.ENGLISH); + @Inject + CurrentUser currentUser; + @GET @Transactional - public TicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = requireSupport(auth); + public TicketListResponse list() { + User user = currentUser.get(); List tickets = Ticket.listAll(); Map lastMessageLabels = new LinkedHashMap<>(); for (Message message : MessageVisibilitySupport @@ -69,9 +76,9 @@ public TicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) @GET @Path("/bootstrap") @Transactional - public TicketFormResponse bootstrap(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("ticketId") Long ticketId, @QueryParam("companyId") Long companyId) { - User user = requireSupport(auth); + public TicketFormResponse bootstrap(@QueryParam("ticketId") Long ticketId, + @QueryParam("companyId") Long companyId) { + User user = currentUser.get(); Ticket ticket = ticketId == null ? new Ticket() : Ticket.find( "select t from Ticket t left join fetch t.companyEntitlement ce left join fetch ce.entitlement left join fetch ce.supportLevel where t.id = ?1", @@ -117,8 +124,8 @@ Category. listAll().stream().map(category -> new CategoryOption(catego @GET @Path("/{id}") @Transactional - public TicketFormResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - return bootstrap(auth, id, null); + public TicketFormResponse detail(@PathParam("id") Long id) { + return bootstrap(id, null); } private MessageSummary toMessageSummary(Message message) { @@ -184,14 +191,6 @@ private String formatDate(LocalDateTime date) { return date == null ? "-" : DATE_FORMATTER.format(date); } - private User requireSupport(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isSupport(user)) { - throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); - } - return user; - } - public record TicketListResponse(String title, String createPath, List items) { } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/TimezoneResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/TimezoneResource.java index ec664f7d..1b267035 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/TimezoneResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/TimezoneResource.java @@ -11,9 +11,10 @@ import ai.mnemosyne_systems.model.Country; import ai.mnemosyne_systems.model.Timezone; import ai.mnemosyne_systems.model.User; -import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; import io.smallrye.common.annotation.Blocking; -import jakarta.ws.rs.CookieParam; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; @@ -25,14 +26,16 @@ @Path("/timezones") @Produces(MediaType.APPLICATION_JSON) @Blocking +@RolesAllowed({ "admin", "support", "superuser", "tam", "user" }) public class TimezoneResource { + @Inject + CurrentUser currentUser; + @GET - public Response list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("countryId") Long countryId) { - User user = AuthHelper.findUser(auth); - if (user == null) { - return Response.status(Response.Status.UNAUTHORIZED).build(); - } + public Response list(@QueryParam("countryId") Long countryId) { + User user = currentUser.get(); + if (countryId == null) { return Response.ok("[]").build(); } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java index 8f8f3ece..f7db9ec9 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserResource.java @@ -24,6 +24,7 @@ import ai.mnemosyne_systems.util.AttachmentHelper; import ai.mnemosyne_systems.util.AuthHelper; import ai.mnemosyne_systems.util.TicketTimeSupport; +import ai.mnemosyne_systems.util.CurrentUser; import io.quarkus.hibernate.orm.panache.Panache; import io.quarkus.elytron.security.common.BcryptUtil; import io.smallrye.common.annotation.Blocking; @@ -31,7 +32,6 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.FormParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; @@ -55,6 +55,10 @@ @Produces(MediaType.TEXT_HTML) @Blocking public class UserResource { + + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @@ -66,19 +70,19 @@ public class UserResource { @GET @Path("user") - public Object home(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = AuthHelper.findUser(auth); + public Object home() { + User user = currentUser.get(); if (AuthHelper.isSuperuser(user)) { return Response.seeOther(URI.create("/superuser")).build(); } - requireUser(auth); + requireUser(); return Response.seeOther(URI.create("/user/tickets")).build(); } @GET @Path("tam/users") - public Response tamUsersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - User user = requireUser(auth); + public Response tamUsersRoot() { + User user = requireUser(); if (!User.TYPE_TAM.equalsIgnoreCase(user.type)) { throw new WebApplicationException(Response.seeOther(URI.create("/user")).build()); } @@ -87,9 +91,8 @@ public Response tamUsersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { @GET @Path("tam/users/{companyId}") - public Response listTamUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - User user = requireUser(auth); + public Response listTamUsers(@PathParam("companyId") Long companyId) { + User user = requireUser(); if (!User.TYPE_TAM.equalsIgnoreCase(user.type)) { throw new WebApplicationException(Response.seeOther(URI.create("/user")).build()); } @@ -107,9 +110,8 @@ public Response listTamUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("tam/users/{companyId}/create") - public Response createTamUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - User user = requireUser(auth); + public Response createTamUserForm(@PathParam("companyId") Long companyId) { + User user = requireUser(); if (!User.TYPE_TAM.equalsIgnoreCase(user.type)) { throw new WebApplicationException(Response.seeOther(URI.create("/user")).build()); } @@ -128,13 +130,13 @@ public Response createTamUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String au @POST @Path("tam/users") @Transactional - public Response createTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @FormParam("name") String name, - @FormParam("fullName") String fullName, @FormParam("email") String email, - @FormParam("social") String social, @FormParam("phoneNumber") String phoneNumber, - @FormParam("phoneExtension") String phoneExtension, @FormParam("timezoneId") Long timezoneId, - @FormParam("countryId") Long countryId, @FormParam("password") String password, - @FormParam("type") String type, @FormParam("companyId") Long companyId) { - User user = requireUser(auth); + public Response createTamUser(@FormParam("name") String name, @FormParam("fullName") String fullName, + @FormParam("email") String email, @FormParam("social") String social, + @FormParam("phoneNumber") String phoneNumber, @FormParam("phoneExtension") String phoneExtension, + @FormParam("timezoneId") Long timezoneId, @FormParam("countryId") Long countryId, + @FormParam("password") String password, @FormParam("type") String type, + @FormParam("companyId") Long companyId) { + User user = requireUser(); if (!User.TYPE_TAM.equalsIgnoreCase(user.type)) { throw new WebApplicationException(Response.seeOther(URI.create("/user")).build()); } @@ -179,7 +181,7 @@ public Response createTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, } newUser.persist(); eventService.record(newUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_CREATED, company.id, - AuthHelper.findUser(auth).id, "User created"); + user.id, "User created"); boolean exists = company.users.stream() .anyMatch(existing -> existing.id != null && existing.id.equals(newUser.id)); if (!exists) { @@ -190,15 +192,15 @@ public Response createTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("user/tickets") - public Response tickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Response tickets() { + requireUser(); return Response.seeOther(URI.create("/user/tickets")).build(); } @GET @Path("user/tickets/create") - public Response createTicketForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Response createTicketForm() { + requireUser(); return Response.seeOther(URI.create("/user/tickets/new")).build(); } @@ -206,9 +208,8 @@ public Response createTicketForm(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @Path("user/tickets") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response createUserTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { - User user = requireUser(auth); + public Response createUserTicket(@HeaderParam("X-Billetsys-Client") String client, MultipartFormDataInput input) { + User user = requireUser(); String status = AttachmentHelper.readFormValue(input, "status"); String title = Ticket.normalizeTitle(AttachmentHelper.readFormValue(input, "title")); String messageBody = AttachmentHelper.readFormValue(input, "message"); @@ -271,8 +272,8 @@ public Response createUserTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @GET @Path("user/support-users/{id}") - public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response viewSupportUser(@PathParam("id") Long id) { + User user = requireUser(); User supportUser = User.findById(id); if (supportUser == null || !User.TYPE_SUPPORT.equalsIgnoreCase(supportUser.type)) { throw new NotFoundException(); @@ -282,8 +283,8 @@ public Response viewSupportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("user/tam-users/{id}") - public Response viewTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response viewTamUser(@PathParam("id") Long id) { + User user = requireUser(); User tamUser = User.findById(id); if (tamUser == null || !User.TYPE_TAM.equalsIgnoreCase(tamUser.type)) { throw new NotFoundException(); @@ -293,8 +294,8 @@ public Response viewTamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @GET @Path("user/superuser-users/{id}") - public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response viewSuperuser(@PathParam("id") Long id) { + User user = requireUser(); User superuser = User.findById(id); if (superuser == null || !User.TYPE_SUPERUSER.equalsIgnoreCase(superuser.type)) { throw new NotFoundException(); @@ -310,8 +311,8 @@ public Response viewSuperuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("user/user-profiles/{id}") - public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response viewUserProfile(@PathParam("id") Long id) { + User user = requireUser(); User viewedUser = User.findById(id); if (viewedUser == null) { throw new NotFoundException(); @@ -321,8 +322,8 @@ public Response viewUserProfile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @GET @Path("user/companies/{id}") - public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response viewCompany(@PathParam("id") Long id) { + User user = requireUser(); Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -332,22 +333,22 @@ public Response viewCompany(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @P @GET @Path("user/tickets/open") - public Response tamOpenTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Response tamOpenTickets() { + requireUser(); return Response.seeOther(URI.create("/user/tickets/open")).build(); } @GET @Path("user/tickets/closed") - public Response tamClosedTickets(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireUser(auth); + public Response tamClosedTickets() { + requireUser(); return Response.seeOther(URI.create("/user/tickets/closed")).build(); } @GET @Path("user/tickets/{id}") - public Response ticketDetail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response ticketDetail(@PathParam("id") Long id) { + User user = requireUser(); if (findTicketForUser(user, id) == null) { throw new NotFoundException(); } @@ -362,9 +363,8 @@ private List loadMessages(Ticket ticket, Use @Path("user/tickets/{id}/messages") @Consumes(MediaType.MULTIPART_FORM_DATA) @Transactional - public Response addUserMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - MultipartFormDataInput input) { - User user = requireUser(auth); + public Response addUserMessage(@PathParam("id") Long id, MultipartFormDataInput input) { + User user = requireUser(); String body = AttachmentHelper.readFormValue(input, "body"); if (body == null || body.isBlank()) { throw new BadRequestException("Message is required"); @@ -396,8 +396,8 @@ public Response addUserMessage(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("user/tickets/{id}/edit") - public Response ticketEdit(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public Response ticketEdit(@PathParam("id") Long id) { + User user = requireUser(); if (findTicketForUser(user, id) == null) { throw new NotFoundException(); } @@ -407,11 +407,10 @@ public Response ticketEdit(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @Pa @POST @Path("user/tickets/{id}") @Transactional - public Response updateUserTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("title") String title, - @FormParam("affectsVersionId") Long affectsVersionId, + public Response updateUserTicket(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("title") String title, @FormParam("affectsVersionId") Long affectsVersionId, @FormParam("resolvedVersionId") Long resolvedVersionId) { - User user = requireUser(auth); + User user = requireUser(); String normalizedTitle = Ticket.normalizeTitle(title); Ticket ticket = findTicketForUser(user, id); if (ticket == null) { @@ -431,9 +430,8 @@ public Response updateUserTicket(@CookieParam(AuthHelper.AUTH_COOKIE) String aut @GET @Path("users") - public Response listAdminUsersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @QueryParam("companyId") Long companyId) { - requireAdmin(auth); + public Response listAdminUsersRoot(@QueryParam("companyId") Long companyId) { + requireAdmin(); if (companyId != null) { return Response.seeOther(URI.create("/users?companyId=" + companyId)).build(); } @@ -442,9 +440,8 @@ public Response listAdminUsersRoot(@CookieParam(AuthHelper.AUTH_COOKIE) String a @GET @Path("users/{companyId}") - public Response listAdminUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("companyId") Long companyId) { - requireAdmin(auth); + public Response listAdminUsers(@PathParam("companyId") Long companyId) { + requireAdmin(); Company selectedCompany = Company.findById(companyId); if (selectedCompany == null) { throw new NotFoundException(); @@ -454,15 +451,15 @@ public Response listAdminUsers(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @GET @Path("users/create") - public Response createAdminUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth) { - requireAdmin(auth); + public Response createAdminUserForm() { + requireAdmin(); return Response.seeOther(URI.create("/users/new")).build(); } @GET @Path("user/{id}/edit") - public Response editAdminUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response editAdminUserForm(@PathParam("id") Long id) { + requireAdmin(); User editUser = User.findById(id); if (editUser == null) { throw new NotFoundException(); @@ -472,8 +469,8 @@ public Response editAdminUserForm(@CookieParam(AuthHelper.AUTH_COOKIE) String au @GET @Path("user/{id}") - public Response viewAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - requireAdmin(auth); + public Response viewAdminUser(@PathParam("id") Long id) { + requireAdmin(); User viewUser = User.findById(id); if (viewUser == null) { throw new NotFoundException(); @@ -484,14 +481,13 @@ public Response viewAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @POST @Path("users") @Transactional - public Response createAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, + public Response createAdminUser(@HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, @FormParam("fullName") String fullName, @FormParam("email") String email, @FormParam("social") String social, @FormParam("phoneNumber") String phoneNumber, @FormParam("phoneExtension") String phoneExtension, @FormParam("timezoneId") Long timezoneId, @FormParam("countryId") Long countryId, @FormParam("type") String type, @FormParam("password") String password, @FormParam("companyId") Long companyId) { - requireAdmin(auth); + User user = requireAdmin(); validateUserFields(name, email, type, true, password); User newUser = new User(); newUser.name = name.trim(); @@ -509,8 +505,8 @@ public Response createAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth newUser.passwordHash = BcryptUtil.bcryptHash(password); } newUser.persist(); - eventService.record(newUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_CREATED, null, - AuthHelper.findUser(auth).id, "User created"); + eventService.record(newUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_CREATED, null, user.id, + "User created"); if (companyId != null) { Company company = Company.findById(companyId); if (company != null) { @@ -523,14 +519,13 @@ public Response createAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth @POST @Path("user/{id}") @Transactional - public Response updateAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id, - @HeaderParam("X-Billetsys-Client") String client, @FormParam("name") String name, - @FormParam("fullName") String fullName, @FormParam("email") String email, + public Response updateAdminUser(@PathParam("id") Long id, @HeaderParam("X-Billetsys-Client") String client, + @FormParam("name") String name, @FormParam("fullName") String fullName, @FormParam("email") String email, @FormParam("social") String social, @FormParam("phoneNumber") String phoneNumber, @FormParam("phoneExtension") String phoneExtension, @FormParam("timezoneId") Long timezoneId, @FormParam("countryId") Long countryId, @FormParam("type") String type, @FormParam("password") String password, @FormParam("companyId") Long companyId) { - requireAdmin(auth); + requireAdmin(); User editUser = User.findById(id); if (editUser == null) { throw new NotFoundException(); @@ -576,16 +571,15 @@ private String trimOrNull(String value) { @POST @Path("user/{id}/delete") @Transactional - public Response deleteAdminUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { - requireAdmin(auth); + public Response deleteAdminUser(@HeaderParam("X-Billetsys-Client") String client, @PathParam("id") Long id) { + User user = requireAdmin(); User deleteUser = User.findById(id); if (deleteUser == null) { throw new NotFoundException(); } removeUserReferences(deleteUser); - eventService.record(deleteUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_DELETED, null, - AuthHelper.findUser(auth).id, "User deleted"); + eventService.record(deleteUser.id, ai.mnemosyne_systems.model.event.EventConstants.USER_DELETED, null, user.id, + "User deleted"); deleteUser.delete(); return ReactRedirectSupport.redirect(client, "/users"); } @@ -1095,16 +1089,16 @@ private String typeLabel(String type) { }; } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); + private User requireUser() { + User user = currentUser.get(); if (!AuthHelper.isUser(user)) { throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); } return user; } - private User requireAdmin(String auth) { - User user = AuthHelper.findUser(auth); + private User requireAdmin() { + User user = currentUser.get(); if (!AuthHelper.isAdmin(user)) { throw new WebApplicationException(Response.seeOther(URI.create("/")).build()); } diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java index 51d91de8..0e1fa7e4 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserTicketApiResource.java @@ -20,9 +20,10 @@ import ai.mnemosyne_systems.service.CrossReferenceService; import ai.mnemosyne_systems.service.EventService; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; @@ -41,8 +42,12 @@ @Path("/api/user/tickets") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({ "tam", "user" }) public class UserTicketApiResource { + @Inject + CurrentUser currentUser; + @Inject CrossReferenceService crossReferenceService; @@ -54,11 +59,11 @@ public class UserTicketApiResource { @GET @Transactional - public SupportTicketApiResource.SupportTicketListResponse list(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, + public SupportTicketApiResource.SupportTicketListResponse list( @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, @QueryParam("page") Integer page, @QueryParam("pageSize") Integer pageSize, @QueryParam("sort") String sort, @QueryParam("dir") String dir) { - User user = requireUser(auth); + User user = currentUser.get(); UserResource.SupportTicketData data = userResource.buildTicketDataForUser(user); String normalizedView = normalizeView(view); List tickets = switch (normalizedView) { @@ -91,10 +96,10 @@ public SupportTicketApiResource.SupportTicketListResponse list(@CookieParam(Auth @GET @Path("/suggest") @Transactional - public SupportTicketApiResource.TicketSuggestionResponse suggest(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, + public SupportTicketApiResource.TicketSuggestionResponse suggest( @QueryParam("view") @DefaultValue("assigned") String view, @QueryParam("q") String q, @QueryParam("exclude") Long exclude) { - User user = requireUser(auth); + User user = currentUser.get(); UserResource.SupportTicketData data = userResource.buildTicketDataForUser(user); List tickets = TicketSearchSupport.combineTickets(data.assignedTickets, data.openTickets, data.closedTickets); @@ -110,10 +115,9 @@ public SupportTicketApiResource.TicketSuggestionResponse suggest(@CookieParam(Au @GET @Path("/bootstrap") @Transactional - public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap( - @CookieParam(AuthHelper.AUTH_COOKIE) String auth, @QueryParam("companyId") Long companyId, + public SupportTicketApiResource.SupportTicketBootstrapResponse bootstrap(@QueryParam("companyId") Long companyId, @QueryParam("companyEntitlementId") Long companyEntitlementId) { - User user = requireUser(auth); + User user = currentUser.get(); UserResource.SupportTicketData data = userResource.buildTicketDataForUser(user); List companies = Company .find("select distinct c from Company c join c.users u where u = ?1 order by c.name", user).list(); @@ -151,8 +155,8 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}") @Transactional - public RoleTicketDetailResponse detail(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, @PathParam("id") Long id) { - User user = requireUser(auth); + public RoleTicketDetailResponse detail(@PathParam("id") Long id) { + User user = currentUser.get(); Ticket ticket = userResource.findTicketForUser(user, id); if (ticket == null) { throw new NotFoundException(); @@ -215,9 +219,8 @@ Category. list("order by name").stream().map(this::toCategoryOption).t @GET @Path("/{id}/references") @Transactional - public CrossReferenceService.CrossReferencesResponse references(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User user = requireUser(auth); + public CrossReferenceService.CrossReferencesResponse references(@PathParam("id") Long id) { + User user = currentUser.get(); Ticket ticket = userResource.findTicketForUser(user, id); if (ticket == null) { throw new NotFoundException(); @@ -372,14 +375,6 @@ private String userPath(User user) { }; } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isUser(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } - public record RoleTicketDetailResponse(Long id, String name, String title, String displayStatus, int assignedCount, int openCount, Long companyId, String companyName, Long categoryId, String categoryName, Long companyEntitlementId, String entitlementName, String levelName, String externalIssueLink, diff --git a/src/backend/main/java/ai/mnemosyne_systems/resource/UserViewApiResource.java b/src/backend/main/java/ai/mnemosyne_systems/resource/UserViewApiResource.java index 83102404..8b5d2a0c 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/resource/UserViewApiResource.java +++ b/src/backend/main/java/ai/mnemosyne_systems/resource/UserViewApiResource.java @@ -11,8 +11,10 @@ import ai.mnemosyne_systems.model.Company; import ai.mnemosyne_systems.model.User; import ai.mnemosyne_systems.util.AuthHelper; +import ai.mnemosyne_systems.util.CurrentUser; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; @@ -25,39 +27,38 @@ @Path("/api/user") @Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({ "tam", "user" }) public class UserViewApiResource { + @Inject + CurrentUser currentUser; + @GET @Path("/support-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse supportUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireUser(auth); + public UserDirectoryApiModels.UserDetailResponse supportUser(@PathParam("id") Long id) { return profileDetail(id, User.TYPE_SUPPORT); } @GET @Path("/tam-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse tamUser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireUser(auth); + public UserDirectoryApiModels.UserDetailResponse tamUser(@PathParam("id") Long id) { return profileDetail(id, User.TYPE_TAM); } @GET @Path("/superuser-users/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse superuser(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - User currentUser = requireUser(auth); + public UserDirectoryApiModels.UserDetailResponse superuser(@PathParam("id") Long id) { + User cUser = currentUser.get(); User user = User.findById(id); if (user == null || !User.TYPE_SUPERUSER.equalsIgnoreCase(user.type)) { throw new NotFoundException(); } boolean allowed = Company.count( "select count(c) from Company c join c.users current join c.users viewed where current = ?1 and viewed = ?2", - currentUser, user) > 0; + cUser, user) > 0; if (!allowed) { throw new NotFoundException(); } @@ -67,9 +68,7 @@ public UserDirectoryApiModels.UserDetailResponse superuser(@CookieParam(AuthHelp @GET @Path("/user-profiles/{id}") @Transactional - public UserDirectoryApiModels.UserDetailResponse profile(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireUser(auth); + public UserDirectoryApiModels.UserDetailResponse profile(@PathParam("id") Long id) { User user = User.findById(id); if (user == null) { throw new NotFoundException(); @@ -80,9 +79,7 @@ public UserDirectoryApiModels.UserDetailResponse profile(@CookieParam(AuthHelper @GET @Path("/companies/{id}") @Transactional - public UserDirectoryApiModels.CompanyDetailResponse company(@CookieParam(AuthHelper.AUTH_COOKIE) String auth, - @PathParam("id") Long id) { - requireUser(auth); + public UserDirectoryApiModels.CompanyDetailResponse company(@PathParam("id") Long id) { Company company = Company.findById(id); if (company == null) { throw new NotFoundException(); @@ -122,11 +119,4 @@ private List usersForCompany(Company compa .map(user -> UserDirectoryApiModels.userReference(user, basePath + user.id, null)).toList(); } - private User requireUser(String auth) { - User user = AuthHelper.findUser(auth); - if (!AuthHelper.isUser(user)) { - throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); - } - return user; - } } diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/UserProvisioningService.java b/src/backend/main/java/ai/mnemosyne_systems/service/UserProvisioningService.java new file mode 100644 index 00000000..5528a71a --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/service/UserProvisioningService.java @@ -0,0 +1,75 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.service; + +import ai.mnemosyne_systems.model.User; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.JsonString; +import jakarta.transaction.Transactional; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class UserProvisioningService { + @Transactional + public User getOrCreateUser(JsonWebToken jwt) { + + String keycloakId = jwt.getSubject(); // "sub" + String email = jwt.getClaim("email"); + String username = jwt.getClaim("preferred_username"); + String fullName = jwt.getClaim("name"); + jakarta.json.JsonObject realmAccess = jwt.getClaim("realm_access"); + List roles = realmAccess != null + ? realmAccess.getJsonArray("roles").getValuesAs(JsonString.class).stream().map(JsonString::getString) + .toList() + : List.of(); + + // look up by keycloakId first, fall back to email for existing + // users that were created before keycloakId column was added + User user = User.find("keycloakId", keycloakId).firstResult(); + if (user == null && email != null) { + user = User.find("email", email).firstResult(); + } + + if (user != null) { + // sync fields that may have changed in Keycloak + user.keycloakId = keycloakId; // stamp keycloakId if missing + user.email = email; + user.name = username; + user.fullName = fullName; + user.type = resolveType(roles); + return user; + } + + // JIT provision new user + user = new User(); + user.keycloakId = keycloakId; + user.email = email; + user.name = username; + user.fullName = fullName; + user.type = resolveType(roles); + + user.persist(); + return user; + } + + private String resolveType(List roles) { + if (roles.contains("admin")) + return User.TYPE_ADMIN; + if (roles.contains("support")) + return User.TYPE_SUPPORT; + if (roles.contains("tam")) + return User.TYPE_TAM; + if (roles.contains("superuser")) + return User.TYPE_SUPERUSER; + return User.TYPE_USER; + } +} diff --git a/src/backend/main/java/ai/mnemosyne_systems/util/CurrentUser.java b/src/backend/main/java/ai/mnemosyne_systems/util/CurrentUser.java new file mode 100644 index 00000000..42494230 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/util/CurrentUser.java @@ -0,0 +1,92 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.util; + +import ai.mnemosyne_systems.model.User; +import ai.mnemosyne_systems.service.UserProvisioningService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import org.eclipse.microprofile.jwt.JsonWebToken; + +@RequestScoped +public class CurrentUser { + + @Inject + SecurityIdentity identity; + + @Inject + Instance jwt; + + @Inject + UserProvisioningService provisioningService; + + private User resolved; + private boolean loaded; + + /** + * Returns the User matching the current Keycloak principal. Result is cached per-request so the DB is hit at most + * once. + * + * @throws NotFoundException + * if the token is valid but no matching User exists in the database. + */ + public User get() { + if (!loaded) { + resolved = load(); + loaded = true; + } + return resolved; + } + + /** + * Same as get() but returns null instead of throwing when the user is not found — useful for optional identity + * checks. + */ + public User getOrNull() { + if (!loaded) { + resolved = load(); + loaded = true; + } + return resolved; + } + + public boolean isAnonymous() { + return identity == null || identity.isAnonymous(); + } + + private User load() { + if (isAnonymous()) { + return null; + } + + // Production path — real Keycloak JWT + if (!jwt.isUnsatisfied()) { + try { + JsonWebToken token = jwt.get(); + if (token.getSubject() != null && !token.getSubject().isBlank()) { + return provisioningService.getOrCreateUser(token); + } + } catch (Exception ignored) { + // fall through to principal-name lookup + } + } + + // Test path — @TestSecurity sets principal name to the user() value + // and @JwtSecurity injects claims including email + String principal = identity.getPrincipal().getName(); + User user = User.find("email", principal).firstResult(); + if (user == null) { + user = User.find("name", principal).firstResult(); + } + return user; + } +} diff --git a/src/backend/main/resources/application.properties b/src/backend/main/resources/application.properties index 9aa43f41..7c3e0f8c 100644 --- a/src/backend/main/resources/application.properties +++ b/src/backend/main/resources/application.properties @@ -62,3 +62,33 @@ app.public-base-url=${APP_PUBLIC_BASE_URL:} cap.api.endpoint=${CAP_API_ENDPOINT:} cap.siteverify.url=${CAP_SITEVERIFY_URL:} cap.secret.key=${CAP_SECRET_KEY:} + + +# OIDC Settings + +quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://localhost:8180}/realms/${KEYCLOAK_REALM_NAME:billetsys} +quarkus.oidc.client-id=${KEYCLOAK_BACKEND_CLIENT_ID:billetsys-backend} +quarkus.oidc.credentials.secret=${KEYCLOAK_BACKEND_CLIENT_SECRET:123456} +quarkus.oidc.application-type=service +quarkus.oidc.authentication.scopes=openid,profile,email +quarkus.oidc.roles.role-claim-path=realm_access/roles +quarkus.oidc.token.principal-claim=sub +quarkus.oidc.roles.source=accesstoken + +# Security Configuration +quarkus.security.users.embedded.enabled=false + +# Public endpoints (no authentication required) +quarkus.http.auth.permission.public.paths=/api/public/*,/api/app/session,/api/cap/*,/health/*,/metrics +quarkus.http.auth.permission.public.policy=permit +quarkus.http.auth.permission.public.priority=1 + +# Authenticated endpoints +quarkus.http.auth.permission.authenticated.paths=/api/* +quarkus.http.auth.permission.authenticated.policy=authenticated + +quarkus.http.auth.permission.authenticated.priority=2 + +quarkus.log.category."io.quarkus.oidc".level=DEBUG +quarkus.log.category."io.quarkus.security".level=DEBUG +quarkus.log.level=INFO diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java b/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java index 39cec76f..985b0d39 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/AccessTestSupport.java @@ -24,8 +24,6 @@ import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.service.MailboxPollingService; import ai.mnemosyne_systems.service.TicketEmailService; -import ai.mnemosyne_systems.util.AuthHelper; -import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.hibernate.orm.panache.Panache; import io.quarkus.mailer.Mail; import io.quarkus.mailer.MockMailbox; @@ -59,20 +57,18 @@ abstract class AccessTestSupport { TicketEmailService ticketEmailService; @Transactional - void ensureUser(String name, String email, String type, String password) { + void ensureUser(String name, String email, String type) { User user = User.find("email", email).firstResult(); if (user == null) { user = new User(); user.name = name; user.email = email; user.type = type; - user.passwordHash = BcryptUtil.bcryptHash(password); user.persist(); return; } user.name = name; user.type = type; - user.passwordHash = BcryptUtil.bcryptHash(password); } @Transactional @@ -273,11 +269,11 @@ void ensureDefaultCategories() { question.persist(); } - String login(String username, String password) { - return RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) - .formParam("username", username).formParam("password", password).post("/login").then().statusCode(303) - .extract().cookie(AuthHelper.AUTH_COOKIE); - } + // String login(String username, String password) { + // return RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + // .formParam("username", username).formParam("password", password).post("/login").then().statusCode(303) + // .extract().cookie(AuthHelper.AUTH_COOKIE); + // } @Transactional Entitlement ensureEntitlement(String name, String description) { @@ -519,7 +515,7 @@ void assertTicketCreateMail(Mail mail, Ticket ticket, String actorName, String s Assertions.assertTrue(mail.getHtml().contains(body)); } - Long createCompany(String cookie, String name) { + Long createCompany(String name) { String normalized = name == null ? "company" : name.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); if (normalized.isBlank()) { @@ -527,11 +523,10 @@ Long createCompany(String cookie, String name) { } String superuserUsername = normalized + "-superuser"; String superuserEmail = normalized + "-superuser@testing.com"; - ensureUser(superuserUsername, superuserEmail, User.TYPE_SUPERUSER, "pass"); + ensureUser(superuserUsername, superuserEmail, User.TYPE_SUPERUSER); User superuser = User.find("email", superuserEmail).firstResult(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", name).formParam("userIds", superuser.id) - .post("/companies").then().statusCode(303); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", name) + .formParam("userIds", superuser.id).post("/companies").then().statusCode(303); ai.mnemosyne_systems.model.Company company = ai.mnemosyne_systems.model.Company.find("name", name) .firstResult(); return company == null ? null : company.id; @@ -547,9 +542,9 @@ void setInstallationUse24HourClock(boolean use24HourClock) { } @Transactional - void deleteCompany(String cookie, Long companyId) { - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/companies/" + companyId + "/delete").then().statusCode(303); + void deleteCompany(Long companyId) { + RestAssured.given().redirects().follow(false).post("/companies/" + companyId + "/delete").then() + .statusCode(303); } @Transactional diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/AdminAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/AdminAccessTest.java index fcae78dd..a02a3c30 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/AdminAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/AdminAccessTest.java @@ -46,156 +46,154 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class AdminAccessTest extends AccessTestSupport { @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void adminCanAccessAdminUsers() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - ensureUser("floating-user", "floating-user@mnemosyne-systems.ai", User.TYPE_USER, "password"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("floating-user", "floating-user@mnemosyne-systems.ai", User.TYPE_USER); ensureCompanyIfMissing("Test Co"); - String cookie = login("admin", "admin"); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/users").then() - .statusCode(303).header("Location", Matchers.endsWith("/users")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/admin/users").then().statusCode(200) - .body("title", Matchers.equalTo("Users")).body("showCompanySelector", Matchers.equalTo(true)) - .body("selectedCompanyId", Matchers.notNullValue()).body("items", Matchers.not(Matchers.empty())); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("companyId", 0).get("/api/admin/users") - .then().statusCode(200).body("selectedCompanyId", Matchers.equalTo(0)) - .body("companies.name", Matchers.hasItem("Unassigned")) + + RestAssured.given().redirects().follow(false).get("/users").then().statusCode(303).header("Location", + Matchers.endsWith("/users")); + RestAssured.given().get("/api/admin/users").then().statusCode(200).body("title", Matchers.equalTo("Users")) + .body("showCompanySelector", Matchers.equalTo(true)).body("selectedCompanyId", Matchers.notNullValue()) + .body("items", Matchers.not(Matchers.empty())); + RestAssured.given().queryParam("companyId", 0).get("/api/admin/users").then().statusCode(200) + .body("selectedCompanyId", Matchers.equalTo(0)).body("companies.name", Matchers.hasItem("Unassigned")) .body("items.email", Matchers.hasItem("floating-user@mnemosyne-systems.ai")); ai.mnemosyne_systems.model.User adminUser = ai.mnemosyne_systems.model.User .find("email", "admin@mnemosyne-systems.ai").firstResult(); Long adminId = adminUser == null ? null : adminUser.id; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/" + adminId) - .then().statusCode(303).header("Location", Matchers.endsWith("/users/" + adminId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/admin/users/" + adminId).then() - .statusCode(200).body("username", Matchers.equalTo("admin")) + RestAssured.given().redirects().follow(false).get("/user/" + adminId).then().statusCode(303).header("Location", + Matchers.endsWith("/users/" + adminId)); + RestAssured.given().get("/api/admin/users/" + adminId).then().statusCode(200) + .body("username", Matchers.equalTo("admin")) .body("editPath", Matchers.equalTo("/users/" + adminId + "/edit")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/admin").then() - .statusCode(303).header("Location", Matchers.endsWith("/")); + RestAssured.given().redirects().follow(false).get("/admin").then().statusCode(303).header("Location", + Matchers.endsWith("/")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/companies").then() - .statusCode(303).header("Location", Matchers.endsWith("/companies")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/owner").then() - .statusCode(303).header("Location", Matchers.endsWith("/owner")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/owner").then().statusCode(200) - .body("name", Matchers.equalTo("mnemosyne systems")).body("use24HourClock", Matchers.equalTo(false)) - .body("supportOptions", Matchers.not(Matchers.empty())) + RestAssured.given().redirects().follow(false).get("/companies").then().statusCode(303).header("Location", + Matchers.endsWith("/companies")); + RestAssured.given().redirects().follow(false).get("/owner").then().statusCode(303).header("Location", + Matchers.endsWith("/owner")); + RestAssured.given().get("/api/owner").then().statusCode(200).body("name", Matchers.equalTo("mnemosyne systems")) + .body("use24HourClock", Matchers.equalTo(false)).body("supportOptions", Matchers.not(Matchers.empty())) .body("tamOptions", Matchers.not(Matchers.empty())); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/owner/edit").then() - .statusCode(303).header("Location", Matchers.endsWith("/owner/edit")); + RestAssured.given().redirects().follow(false).get("/owner/edit").then().statusCode(303).header("Location", + Matchers.endsWith("/owner/edit")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/users/create").then() - .statusCode(303).header("Location", Matchers.endsWith("/users/new")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/admin/users/bootstrap").then() - .statusCode(200).body("title", Matchers.equalTo("New user")) - .body("passwordRequired", Matchers.equalTo(true)).body("user.type", Matchers.equalTo("user")) - .body("user.name", Matchers.equalTo("")).body("user.email", Matchers.equalTo("")); + RestAssured.given().redirects().follow(false).get("/users/create").then().statusCode(303).header("Location", + Matchers.endsWith("/users/new")); + RestAssured.given().get("/api/admin/users/bootstrap").then().statusCode(200) + .body("title", Matchers.equalTo("New user")).body("passwordRequired", Matchers.equalTo(true)) + .body("user.type", Matchers.equalTo("user")).body("user.name", Matchers.equalTo("")) + .body("user.email", Matchers.equalTo("")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/entitlements").then() - .statusCode(303).header("Location", Matchers.endsWith("/entitlements")); + RestAssured.given().redirects().follow(false).get("/entitlements").then().statusCode(303).header("Location", + Matchers.endsWith("/entitlements")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/levels").then() - .statusCode(303).header("Location", Matchers.endsWith("/levels")); + RestAssured.given().redirects().follow(false).get("/levels").then().statusCode(303).header("Location", + Matchers.endsWith("/levels")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/companies/create") - .then().statusCode(303).header("Location", Matchers.endsWith("/companies/new")); + RestAssured.given().redirects().follow(false).get("/companies/create").then().statusCode(303).header("Location", + Matchers.endsWith("/companies/new")); - Long companyId = createCompany(cookie, "Cycle Co"); - deleteCompany(cookie, companyId); + Long companyId = createCompany("Cycle Co"); + deleteCompany(companyId); } @Test + @TestSecurity(user = "admin2@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin2@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void adminCanManageSupportCatalogAndCompanies() { - ensureUser("admin2", "admin2@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin2"); - String cookie = login("admin2", "admin2"); + ensureUser("admin2", "admin2@mnemosyne-systems.ai", User.TYPE_ADMIN); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/entitlements").then() - .statusCode(303).header("Location", Matchers.endsWith("/entitlements")); + RestAssured.given().redirects().follow(false).get("/entitlements").then().statusCode(303).header("Location", + Matchers.endsWith("/entitlements")); String entitlementName = "Test Entitlement"; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", entitlementName) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", entitlementName) .formParam("description", "Test description").formParam("versionNames", "1.0.0") .formParam("versionDates", "2026-01-01").post("/entitlements").then().statusCode(303); Entitlement entitlement = Entitlement.find("name", entitlementName).firstResult(); Assertions.assertNotNull(entitlement); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", "Updated Entitlement") - .formParam("description", "Updated description").formParam("versionNames", "1.0.1") - .formParam("versionDates", "2026-02-01").post("/entitlements/" + entitlement.id).then().statusCode(303); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("name", "Updated Entitlement").formParam("description", "Updated description") + .formParam("versionNames", "1.0.1").formParam("versionDates", "2026-02-01") + .post("/entitlements/" + entitlement.id).then().statusCode(303); Entitlement updatedEntitlement = refreshedEntitlement(entitlement.id); Assertions.assertEquals("Updated Entitlement", updatedEntitlement.name); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/entitlements/" + entitlement.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/entitlements/" + entitlement.id).then().statusCode(303) .header("Location", Matchers.endsWith("/entitlements/" + entitlement.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/entitlements/" + entitlement.id + "/edit").then().statusCode(303) - .header("Location", Matchers.endsWith("/entitlements/" + entitlement.id + "/edit")); + RestAssured.given().redirects().follow(false).get("/entitlements/" + entitlement.id + "/edit").then() + .statusCode(303).header("Location", Matchers.endsWith("/entitlements/" + entitlement.id + "/edit")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/entitlements/" + entitlement.id + "/delete").then().statusCode(303); + RestAssured.given().redirects().follow(false).post("/entitlements/" + entitlement.id + "/delete").then() + .statusCode(303); Assertions.assertNull(refreshedEntitlement(entitlement.id)); String levelName = "Test Level"; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", levelName) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", levelName) .formParam("description", "Level description").formParam("level", 30).formParam("color", "Red") .post("/levels").then().statusCode(303); Level level = Level.find("name", levelName).firstResult(); Assertions.assertNotNull(level); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", "Updated Level") + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", "Updated Level") .formParam("description", "Updated level").formParam("level", 45).formParam("color", "Yellow") .post("/levels/" + level.id).then().statusCode(303); Level updatedLevel = refreshedLevel(level.id); Assertions.assertEquals("Updated Level", updatedLevel.name); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/levels").then() - .statusCode(303).header("Location", Matchers.endsWith("/levels")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/levels/" + level.id + "/edit").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/levels").then().statusCode(303).header("Location", + Matchers.endsWith("/levels")); + RestAssured.given().redirects().follow(false).get("/levels/" + level.id + "/edit").then().statusCode(303) .header("Location", Matchers.endsWith("/levels/" + level.id + "/edit")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/levels/" + level.id + "/delete").then().statusCode(303); + RestAssured.given().redirects().follow(false).post("/levels/" + level.id + "/delete").then().statusCode(303); Assertions.assertNull(refreshedLevel(level.id)); - ensureUser("coverage-superuser", "coverage-superuser@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "password"); + ensureUser("coverage-superuser", "coverage-superuser@mnemosyne-systems.ai", User.TYPE_SUPERUSER); User coverageSuperuser = User.find("email", "coverage-superuser@mnemosyne-systems.ai").firstResult(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", "Coverage Co") + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", "Coverage Co") .formParam("userIds", coverageSuperuser.id).post("/companies").then().statusCode(303); Company company = Company.find("name", "Coverage Co").firstResult(); Assertions.assertNotNull(company); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", "Coverage Co Updated") - .formParam("country", "United States of America").post("/companies/" + company.id).then() - .statusCode(303); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("name", "Coverage Co Updated").formParam("country", "United States of America") + .post("/companies/" + company.id).then().statusCode(303); Company updatedCompany = refreshedCompany(company.id); Assertions.assertEquals("Coverage Co Updated", updatedCompany.name); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/companies/" + company.id + "/delete").then().statusCode(303); + RestAssured.given().redirects().follow(false).post("/companies/" + company.id + "/delete").then() + .statusCode(303); Assertions.assertNull(refreshedCompany(company.id)); } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactAppSessionReturnsAdminCategoryLink() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("role", Matchers.equalTo("admin")).body("navigation.href", Matchers.hasItem("/categories")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("role", Matchers.equalTo("admin")) + .body("navigation.href", Matchers.hasItem("/categories")) .body("installationLogoBase64", Matchers.startsWith("data:image/svg+xml;base64,")) .body("installationBackgroundBase64", Matchers.anyOf(Matchers.nullValue(), Matchers.startsWith("data:image/"))) @@ -208,12 +206,14 @@ void reactAppSessionReturnsAdminCategoryLink() { } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactAppSessionReturnsAdminOwnerLink() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("role", Matchers.equalTo("admin")).body("navigation.href", Matchers.hasItem("/owner")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("role", Matchers.equalTo("admin")) + .body("navigation.href", Matchers.hasItem("/owner")) .body("installationLogoBase64", Matchers.startsWith("data:image/svg+xml;base64,")) .body("installationBackgroundBase64", Matchers.anyOf(Matchers.nullValue(), Matchers.startsWith("data:image/"))) @@ -226,12 +226,14 @@ void reactAppSessionReturnsAdminOwnerLink() { } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactAppSessionReturnsAdminCompanyLink() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("role", Matchers.equalTo("admin")).body("navigation.href", Matchers.hasItem("/companies")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("role", Matchers.equalTo("admin")) + .body("navigation.href", Matchers.hasItem("/companies")) .body("installationLogoBase64", Matchers.startsWith("data:image/svg+xml;base64,")) .body("installationBackgroundBase64", Matchers.anyOf(Matchers.nullValue(), Matchers.startsWith("data:image/"))) @@ -243,38 +245,42 @@ void reactAppSessionReturnsAdminCompanyLink() { .body("inactivityWarningSeconds", Matchers.equalTo(AuthHelper.WARNING_LEAD_SECONDS)); } + // @Test + // void reactLoginRedirectsAdminToAdminDashboard() { + // ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + // + // RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("username", "admin") + // .formParam("password", "admin").post("/login").then().statusCode(303) + // .header("Location", Matchers.equalTo("/")); + // } @Test - void reactLoginRedirectsAdminToAdminDashboard() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - - RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("username", "admin") - .formParam("password", "admin").post("/login").then().statusCode(303) - .header("Location", Matchers.equalTo("/")); - } - - @Test + @TestSecurity(user = "support1@mnemosyne-systems.ai", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactCategoriesApiRequiresAdminPrivileges() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - String cookie = login("support1", "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/categories").then().statusCode(401); + RestAssured.given().get("/api/categories").then().statusCode(403); } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactCategoriesApiReturnsAdminCategoryListAndDetail() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + Category category = ensureCategory("Escalation", "First line\n\nExtra detail", true); Attachment attachment = ensureCategoryAttachment(category, "category-note.txt", "Category attachment"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/categories").then().statusCode(200) - .body("canCreate", Matchers.equalTo(true)).body("createPath", Matchers.equalTo("/categories/new")) + RestAssured.given().get("/api/categories").then().statusCode(200).body("canCreate", Matchers.equalTo(true)) + .body("createPath", Matchers.equalTo("/categories/new")) .body("items.name", Matchers.hasItem("Escalation")) .body("items.find { it.name == 'Escalation' }.descriptionPreview", Matchers.equalTo("First line")) .body("items.find { it.name == 'Escalation' }.isDefault", Matchers.equalTo(true)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/categories/" + category.id).then() - .statusCode(200).body("name", Matchers.equalTo("Escalation")) + RestAssured.given().get("/api/categories/" + category.id).then().statusCode(200) + .body("name", Matchers.equalTo("Escalation")) .body("description", Matchers.containsString("Extra detail")).body("isDefault", Matchers.equalTo(true)) .body("editPath", Matchers.equalTo("/categories/" + category.id + "/edit")) .body("attachments.name", Matchers.hasItem("category-note.txt")) @@ -282,42 +288,49 @@ void reactCategoriesApiReturnsAdminCategoryListAndDetail() { } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactArticlesApiReturnsAdminArticleListAndDetail() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + Article article = ensureArticle("Admin Runbook", "ops,admin", "## Admin article\n\nSteps"); Attachment attachment = ensureArticleAttachment(article, "admin-runbook.txt", "Admin attachment"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/articles").then().statusCode(200) - .body("canCreate", Matchers.equalTo(true)).body("createPath", Matchers.equalTo("/articles/new")) + RestAssured.given().get("/api/articles").then().statusCode(200).body("canCreate", Matchers.equalTo(true)) + .body("createPath", Matchers.equalTo("/articles/new")) .body("items.title", Matchers.hasItem("Admin Runbook")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/articles/" + article.id).then() - .statusCode(200).body("title", Matchers.equalTo("Admin Runbook")) - .body("tags", Matchers.equalTo("ops,admin")).body("body", Matchers.containsString("Admin article")) - .body("canEdit", Matchers.equalTo(true)).body("canDelete", Matchers.equalTo(true)) + RestAssured.given().get("/api/articles/" + article.id).then().statusCode(200) + .body("title", Matchers.equalTo("Admin Runbook")).body("tags", Matchers.equalTo("ops,admin")) + .body("body", Matchers.containsString("Admin article")).body("canEdit", Matchers.equalTo(true)) + .body("canDelete", Matchers.equalTo(true)) .body("editPath", Matchers.equalTo("/articles/" + article.id + "/edit")) .body("attachments.name", Matchers.hasItem("admin-runbook.txt")) .body("attachments.downloadPath", Matchers.hasItem("/attachments/" + attachment.id + "/data")); } @Test + @TestSecurity(user = "support1@mnemosyne-systems.ai", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactCompaniesApiRequiresAdminPrivileges() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - String cookie = login("support1", "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/companies").then().statusCode(401); + RestAssured.given().get("/api/companies").then().statusCode(403); } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactCompaniesApiReturnsAdminCompanyListAndDetail() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("assigned-user", "assigned-user@mnemosyne-systems.ai", User.TYPE_USER, "assigned-user"); - ensureUser("assigned-superuser", "assigned-superuser@mnemosyne-systems.ai", User.TYPE_SUPERUSER, - "assigned-superuser"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("assigned-user", "assigned-user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("assigned-superuser", "assigned-superuser@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + Long companyId = ensureCompany("React Company"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Long assignedCompanyId = ensureCompany("Assigned Company"); @@ -328,14 +341,14 @@ void reactCompaniesApiReturnsAdminCompanyListAndDetail() { Level level = ensureLevel("React Level", "React level detail", 60, "Blue"); ensureCompanyEntitlement(company, entitlement, level); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/companies").then().statusCode(200) + RestAssured.given().get("/api/companies").then().statusCode(200) .body("createPath", Matchers.equalTo("/companies/new")) .body("items.name", Matchers.hasItem("React Company")) .body("items.find { it.name == 'React Company' }.detailPath", Matchers.equalTo("/companies/" + companyId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/companies/" + companyId).then() - .statusCode(200).body("name", Matchers.equalTo("React Company")) + RestAssured.given().get("/api/companies/" + companyId).then().statusCode(200) + .body("name", Matchers.equalTo("React Company")) .body("selectedUsers.email", Matchers.hasItem("user@mnemosyne-systems.ai")) .body("selectedTams.email", Matchers.hasItem("tam1@mnemosyne-systems.ai")) .body("userOptions.email", Matchers.not(Matchers.hasItem("assigned-user@mnemosyne-systems.ai"))) @@ -346,18 +359,20 @@ void reactCompaniesApiReturnsAdminCompanyListAndDetail() { } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void adminCanUpdateCompanyWithEntitlementAssignments() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - String cookie = login("admin", "admin"); - Long companyId = createCompany(cookie, "Entitlement Update Co"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + + Long companyId = createCompany("Entitlement Update Co"); Company company = refreshedCompany(companyId); Assertions.assertNotNull(company); Entitlement entitlement = ensureEntitlement("Company Update Entitlement", "Update detail"); Level level = ensureLevel("Company Update Level", "Update level", 50, "Orange"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("name", company.name) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", company.name) .formParam("entitlementIds", entitlement.id).formParam("levelIds", level.id) .formParam("entitlementDates", "2026-03-27") .formParam("entitlementDurations", CompanyEntitlement.DURATION_YEARLY).post("/companies/" + company.id) @@ -373,25 +388,29 @@ void adminCanUpdateCompanyWithEntitlementAssignments() { Company updatedCompany = refreshedCompany(company.id); Assertions.assertTrue(companyHasUser(updatedCompany.id, "entitlement-update-co-superuser@testing.com")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/companies/" + company.id).then() - .statusCode(200) - .body("selectedSuperusers.email", Matchers.hasItem("entitlement-update-co-superuser@testing.com")); + RestAssured.given().get("/api/companies/" + company.id).then().statusCode(200).body("selectedSuperusers.email", + Matchers.hasItem("entitlement-update-co-superuser@testing.com")); } @Test + @TestSecurity(user = "support1@mnemosyne-systems.ai", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactOwnerApiRequiresAdminPrivileges() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - String cookie = login("support1", "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/owner").then().statusCode(401); + RestAssured.given().get("/api/owner").then().statusCode(403); } @Test + @TestSecurity(user = "admin@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) void reactOwnerApiReturnsAndUpdatesOwnerDetails() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - String cookie = login("admin", "admin"); + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + Company ownerCompany = ownerCompany(); Country country = Country.find("code", "US").firstResult(); Timezone timezone = Timezone.find("country = ?1 order by name", country).firstResult(); @@ -400,7 +419,7 @@ void reactOwnerApiReturnsAndUpdatesOwnerDetails() { String installationLogo = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNCIgZmlsbD0iIzEyMzQ1NiIvPjwvc3ZnPg=="; String installationBackground = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jx6kAAAAASUVORK5CYII="; - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/owner").then().statusCode(200) + RestAssured.given().get("/api/owner").then().statusCode(200) .body("id", Matchers.equalTo(ownerCompany.id.intValue())) .body("name", Matchers.equalTo(ownerCompany.name)).body("countries.size()", Matchers.greaterThan(0)) .body("timezones.size()", Matchers.greaterThan(0)) @@ -412,7 +431,7 @@ void reactOwnerApiReturnsAndUpdatesOwnerDetails() { .body("supportOptions.email", Matchers.hasItem("support1@mnemosyne-systems.ai")) .body("tamOptions.email", Matchers.hasItem("tam1@mnemosyne-systems.ai")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.JSON) + RestAssured.given().contentType(ContentType.JSON) .body(Map.ofEntries(Map.entry("name", "mnemosyne systems"), Map.entry("address1", "Owner Street 1"), Map.entry("address2", "Owner Street 2"), Map.entry("city", "Owner City"), Map.entry("state", "Owner State"), Map.entry("zip", "12345"), @@ -445,7 +464,7 @@ void reactOwnerApiReturnsAndUpdatesOwnerDetails() { Assertions.assertEquals(installationBackground, installation.backgroundBase64); Assertions.assertTrue(companyHasUser(ownerCompany.id, "support1@mnemosyne-systems.ai")); Assertions.assertTrue(companyHasUser(ownerCompany.id, "tam1@mnemosyne-systems.ai")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) + RestAssured.given().get("/api/app/session").then().statusCode(200) .body("installationLogoBase64", Matchers.equalTo(installationLogo)) .body("installationBackgroundBase64", Matchers.equalTo(installationBackground)) .body("installationHeaderFooterColor", Matchers.equalTo("#123456")) @@ -455,25 +474,26 @@ void reactOwnerApiReturnsAndUpdatesOwnerDetails() { } @Test + @TestSecurity(user = "admin-json@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin-json@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin-json") }) void reactAdminMutationsReturnJsonRedirects() { - ensureUser("admin-json", "admin-json@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin-json"); - String cookie = login("admin-json", "admin-json"); + ensureUser("admin-json", "admin-json@mnemosyne-systems.ai", User.TYPE_ADMIN); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("name", "react-admin-user") - .formParam("email", "react-admin-user@mnemosyne-systems.ai").formParam("type", User.TYPE_USER) - .formParam("password", "secret").post("/users").then().statusCode(200) + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("name", "react-admin-user").formParam("email", "react-admin-user@mnemosyne-systems.ai") + .formParam("type", User.TYPE_USER).formParam("password", "secret").post("/users").then().statusCode(200) .body("redirectTo", Matchers.equalTo("/users")); User createdUser = User.find("email", "react-admin-user@mnemosyne-systems.ai").firstResult(); Assertions.assertNotNull(createdUser); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("name", "react-admin-user") + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("name", "react-admin-user") .formParam("email", "react-admin-user-duplicate@mnemosyne-systems.ai").formParam("type", User.TYPE_USER) .formParam("password", "secret").post("/users").then().statusCode(400); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("name", "renamed-admin-user") + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("name", "renamed-admin-user") .formParam("email", "react-admin-user-updated@mnemosyne-systems.ai") .formParam("type", User.TYPE_SUPPORT).post("/user/" + createdUser.id).then().statusCode(200) .body("redirectTo", Matchers.equalTo("/users")); @@ -482,28 +502,27 @@ void reactAdminMutationsReturnJsonRedirects() { Assertions.assertEquals("react-admin-user-updated@mnemosyne-systems.ai", refreshedCreatedUser.email); Assertions.assertEquals(User.TYPE_SUPPORT, refreshedCreatedUser.type); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .multiPart("name", "React Redirect Category").multiPart("description", "Redirect body") - .multiPart("isDefault", "false").post("/categories").then().statusCode(200) - .body("redirectTo", Matchers.equalTo("/categories")); + RestAssured.given().header("X-Billetsys-Client", "react").multiPart("name", "React Redirect Category") + .multiPart("description", "Redirect body").multiPart("isDefault", "false").post("/categories").then() + .statusCode(200).body("redirectTo", Matchers.equalTo("/categories")); Category category = Category.find("name", "React Redirect Category").firstResult(); Assertions.assertNotNull(category); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .post("/categories/" + category.id + "/delete").then().statusCode(200) - .body("redirectTo", Matchers.equalTo("/categories")); + RestAssured.given().header("X-Billetsys-Client", "react").post("/categories/" + category.id + "/delete").then() + .statusCode(200).body("redirectTo", Matchers.equalTo("/categories")); } @Test + @TestSecurity(user = "admin-delete@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin-delete@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin-delete") }) void adminCanDeleteReferencedUserAssignedToCompany() { - ensureUser("admin-delete", "admin-delete@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin-delete"); - ensureUser("delete-target", "delete-target@mnemosyne-systems.ai", User.TYPE_USER, "delete-target"); - String cookie = login("admin-delete", "admin-delete"); + ensureUser("admin-delete", "admin-delete@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("delete-target", "delete-target@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Delete User Co"); Long targetUserId = prepareReferencedUserForDelete(companyId, "delete-target@mnemosyne-systems.ai", true); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/user/" + targetUserId + "/delete").then().statusCode(303) + RestAssured.given().redirects().follow(false).post("/user/" + targetUserId + "/delete").then().statusCode(303) .header("Location", Matchers.endsWith("/users")); Assertions.assertNull(refreshedUser(targetUserId)); @@ -513,15 +532,16 @@ void adminCanDeleteReferencedUserAssignedToCompany() { } @Test + @TestSecurity(user = "admin-delete-2@mnemosyne-systems.ai", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin-delete-2@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin-delete-2") }) void adminCanDeleteReferencedNonSuperuserWithoutServerError() { - ensureUser("admin-delete-2", "admin-delete-2@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin-delete-2"); - ensureUser("delete-target-2", "delete-target-2@mnemosyne-systems.ai", User.TYPE_USER, "delete-target-2"); - String cookie = login("admin-delete-2", "admin-delete-2"); + ensureUser("admin-delete-2", "admin-delete-2@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("delete-target-2", "delete-target-2@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Delete User Co 2"); Long targetUserId = prepareReferencedUserForDelete(companyId, "delete-target-2@mnemosyne-systems.ai", false); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/user/" + targetUserId + "/delete").then().statusCode(303) + RestAssured.given().redirects().follow(false).post("/user/" + targetUserId + "/delete").then().statusCode(303) .header("Location", Matchers.endsWith("/users")); Assertions.assertNull(refreshedUser(targetUserId)); diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/MessagePrivacyAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/MessagePrivacyAccessTest.java index ed9607f2..336b6a05 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/MessagePrivacyAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/MessagePrivacyAccessTest.java @@ -22,16 +22,23 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class MessagePrivacyAccessTest extends AccessTestSupport { @Test - void privateMessagesAreVisibleOnlyToMatchingGroups() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void privateMessagesAreVisibleToSupport() { + // [CHANGE] ensureUser calls — removed password argument (Keycloak owns passwords) + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); Long companyId = ensureCompany("Message Privacy Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", @@ -49,39 +56,115 @@ void privateMessagesAreVisibleOnlyToMatchingGroups() { Attachment internalAttachment = ensureAttachment(internalMessage, "internal-private.txt"); Attachment externalAttachment = ensureAttachment(externalMessage, "external-private.txt"); - String supportCookie = login("support1", "support1"); - String tamCookie = login("tam1", "tam1"); - String userCookie = login("user", "user"); - String superuserCookie = login("superuser1", "superuser1"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/api/support/tickets/" + ticket.id) - .then().statusCode(200).body("messages.body", Matchers.hasItems(publicBody, internalBody)) + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) + .body("messages.body", Matchers.hasItems(publicBody, internalBody)) .body("messages.body", Matchers.not(Matchers.hasItem(externalBody))) .body("messages.find { it.body == '" + internalBody + "' }.isPublic", Matchers.equalTo(false)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/api/user/tickets/" + ticket.id).then() - .statusCode(200).body("messages.body", Matchers.hasItems(publicBody, internalBody)) + RestAssured.given().get("/attachments/" + externalAttachment.id + "/data").then().statusCode(404); + } + + @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) + void privateMessagesAreVisibleToTam() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Message Privacy Co TAM"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + String publicBody = "public-visible-tam-" + System.nanoTime(); + String internalBody = "internal-private-tam-" + System.nanoTime(); + String externalBody = "external-private-tam-" + System.nanoTime(); + ensureTimedMessage(ticket, publicBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(1), true); + ensureTimedMessage(ticket, internalBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(2), false); + ensureTimedMessage(ticket, externalBody, "user@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(3), false); + + RestAssured.given().get("/api/user/tickets/" + ticket.id).then().statusCode(200) + .body("messages.body", Matchers.hasItems(publicBody, internalBody)) .body("messages.body", Matchers.not(Matchers.hasItem(externalBody))); + } - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/user/tickets/" + ticket.id).then() - .statusCode(200).body("messages.body", Matchers.hasItems(publicBody, externalBody)) + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void privateMessagesAreVisibleToUser() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Message Privacy Co User"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + String publicBody = "public-visible-user-" + System.nanoTime(); + String internalBody = "internal-private-user-" + System.nanoTime(); + String externalBody = "external-private-user-" + System.nanoTime(); + ensureTimedMessage(ticket, publicBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(1), true); + Message internalMessage = ensureTimedMessage(ticket, internalBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(2), false); + ensureTimedMessage(ticket, externalBody, "user@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(3), false); + Attachment internalAttachment = ensureAttachment(internalMessage, "internal-private.txt"); + + RestAssured.given().get("/api/user/tickets/" + ticket.id).then().statusCode(200) + .body("messages.body", Matchers.hasItems(publicBody, externalBody)) .body("messages.body", Matchers.not(Matchers.hasItem(internalBody))) .body("messages.find { it.body == '" + externalBody + "' }.isPublic", Matchers.equalTo(false)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, superuserCookie).get("/api/superuser/tickets/" + ticket.id) - .then().statusCode(200).body("messages.body", Matchers.hasItems(publicBody, externalBody)) - .body("messages.body", Matchers.not(Matchers.hasItem(internalBody))); + RestAssured.given().get("/api/attachments/" + internalAttachment.id).then().statusCode(404); + } - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/attachments/" + internalAttachment.id) - .then().statusCode(404); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .get("/attachments/" + externalAttachment.id + "/data").then().statusCode(404); + @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser") }) + void privateMessagesAreVisibleToSuperuser() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Message Privacy Co SU"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + String publicBody = "public-visible-su-" + System.nanoTime(); + String internalBody = "internal-private-su-" + System.nanoTime(); + String externalBody = "external-private-su-" + System.nanoTime(); + ensureTimedMessage(ticket, publicBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(1), true); + ensureTimedMessage(ticket, internalBody, "support1@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(2), false); + ensureTimedMessage(ticket, externalBody, "user@mnemosyne-systems.ai", + java.time.LocalDateTime.now().minusMinutes(3), false); + + RestAssured.given().get("/api/superuser/tickets/" + ticket.id).then().statusCode(200) + .body("messages.body", Matchers.hasItems(publicBody, externalBody)) + .body("messages.body", Matchers.not(Matchers.hasItem(internalBody))); } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void privateVisibilityFlagPersistsForCreatedAndReplyMessages() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Private Create Co"); @@ -94,64 +177,79 @@ void privateVisibilityFlagPersistsForCreatedAndReplyMessages() { .firstResult(); Assertions.assertNotNull(affectsVersion); - String supportCookie = login("support1", "support1"); String privateCreateBody = "private-create-message-" + System.nanoTime(); String privateReplyBody = "private-reply-message-" + System.nanoTime(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("status", "Open").multiPart("title", "Private create title") - .multiPart("message", privateCreateBody).multiPart("isPublic", "false") - .multiPart("companyId", companyId).multiPart("companyEntitlementId", entitlement.id) - .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", affectsVersion.id) - .post("/support/tickets").then().statusCode(303); + RestAssured.given().redirects().follow(false).multiPart("status", "Open") + .multiPart("title", "Private create title").multiPart("message", privateCreateBody) + .multiPart("isPublic", "false").multiPart("companyId", companyId) + .multiPart("companyEntitlementId", entitlement.id).multiPart("categoryId", Category.findDefault().id) + .multiPart("affectsVersionId", affectsVersion.id).post("/support/tickets").then().statusCode(303); Message createdMessage = Message.find("body", privateCreateBody).firstResult(); Assertions.assertNotNull(createdMessage); Assertions.assertFalse(createdMessage.isPublic); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("body", privateReplyBody).multiPart("isPublic", "false") + RestAssured.given().redirects().follow(false).multiPart("body", privateReplyBody).multiPart("isPublic", "false") .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); Message replyMessage = Message.find("ticket = ?1 and body = ?2", ticket, privateReplyBody).firstResult(); Assertions.assertNotNull(replyMessage); Assertions.assertFalse(replyMessage.isPublic); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/api/support/tickets/" + ticket.id) - .then().statusCode(200) + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) .body("messages.find { it.body == '" + privateReplyBody + "' }.isPublic", Matchers.equalTo(false)); } @Test - void privateMessageEmailsStayWithinTheirAudience() { + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void privateMessageEmailsFromSupportStayWithinTheirAudience() { mailbox.clear(); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); Long companyId = ensureCompany("Private Mail Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); - String supportCookie = login("support1", "support1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("body", "Private support email " + System.nanoTime()).multiPart("isPublic", "false") - .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); + RestAssured.given().redirects().follow(false).multiPart("body", "Private support email " + System.nanoTime()) + .multiPart("isPublic", "false").post("/support/tickets/" + ticket.id + "/messages").then() + .statusCode(303); + // Internal message (support-authored): only support + tam should receive email Assertions.assertFalse(mailbox.getMailsSentTo("support1@mnemosyne-systems.ai").isEmpty()); Assertions.assertFalse(mailbox.getMailsSentTo("tam1@mnemosyne-systems.ai").isEmpty()); Assertions.assertTrue(mailbox.getMailsSentTo("user@mnemosyne-systems.ai").isEmpty()); Assertions.assertTrue(mailbox.getMailsSentTo("superuser1@mnemosyne-systems.ai").isEmpty()); + } + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void privateMessageEmailsFromUserStayWithinTheirAudience() { mailbox.clear(); + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Private Mail Co User"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); - String userCookie = login("user", "user"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie) - .multiPart("body", "Private requester email " + System.nanoTime()).multiPart("isPublic", "false") - .post("/user/tickets/" + ticket.id + "/messages").then().statusCode(303); + RestAssured.given().redirects().follow(false).multiPart("body", "Private requester email " + System.nanoTime()) + .multiPart("isPublic", "false").post("/user/tickets/" + ticket.id + "/messages").then().statusCode(303); + // External message (user-authored): only user + superuser should receive email Assertions.assertFalse(mailbox.getMailsSentTo("user@mnemosyne-systems.ai").isEmpty()); Assertions.assertFalse(mailbox.getMailsSentTo("superuser1@mnemosyne-systems.ai").isEmpty()); Assertions.assertTrue(mailbox.getMailsSentTo("support1@mnemosyne-systems.ai").isEmpty()); @@ -159,13 +257,16 @@ void privateMessageEmailsStayWithinTheirAudience() { } @Test - void privateMessagesDoNotChangeOverviewSlaColors() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); - - Long companyId = ensureCompany("Private SLA Co"); + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void privateMessagesDoNotChangeUserViewSlaColor() { + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Private SLA Co User"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Level urgent = ensureLevel("Normal", "Immediate response", 1, "Red"); @@ -175,31 +276,48 @@ void privateMessagesDoNotChangeOverviewSlaColors() { ensureTimedMessage(userViewTicket, "Old requester message", "user@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(10), true); - String userCookie = login("user", "user"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/user/tickets").then().statusCode(200) + // SLA color starts as Red (pending requester reply) + RestAssured.given().get("/api/user/tickets").then().statusCode(200) .body("items.find { it.id == " + userViewTicket.id + " }.slaColor", Matchers.equalTo("Red")); + // A private support message must NOT change the SLA color visible to the user ensureTimedMessage(userViewTicket, "Private support update", "support1@mnemosyne-systems.ai", java.time.LocalDateTime.now(), false); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/user/tickets").then().statusCode(200) + RestAssured.given().get("/api/user/tickets").then().statusCode(200) .body("items.find { it.id == " + userViewTicket.id + " }.slaColor", Matchers.equalTo("Red")); + } + + @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void privateMessagesDoNotChangeSupportViewSlaColor() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + + Long companyId = ensureCompany("Private SLA Co Support"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", + "tam1@mnemosyne-systems.ai"); + Level urgent = ensureLevel("Normal", "Immediate response", 1, "Red"); Ticket supportViewTicket = ensureTicket(companyId); setTicketSupportLevel(supportViewTicket.id, urgent.id); ensureTimedMessage(supportViewTicket, "Old support message", "support1@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(10), true); - String supportCookie = login("support1", "support1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/api/support/tickets").then() - .statusCode(200) + // SLA color starts as Red (pending support reply) + RestAssured.given().get("/api/support/tickets").then().statusCode(200) .body("items.find { it.id == " + supportViewTicket.id + " }.slaColor", Matchers.equalTo("Red")); + // A private user message must NOT change the SLA color visible to support ensureTimedMessage(supportViewTicket, "Private user update", "user@mnemosyne-systems.ai", java.time.LocalDateTime.now(), false); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/api/support/tickets").then() - .statusCode(200) + RestAssured.given().get("/api/support/tickets").then().statusCode(200) .body("items.find { it.id == " + supportViewTicket.id + " }.slaColor", Matchers.equalTo("Red")); } } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/PasswordResetResourceTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/PasswordResetResourceTest.java index 69bb1513..0e381fe9 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/PasswordResetResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/PasswordResetResourceTest.java @@ -10,7 +10,6 @@ import ai.mnemosyne_systems.model.PasswordResetToken; import ai.mnemosyne_systems.model.User; -import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.mailer.Mail; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTestProfile; @@ -56,8 +55,6 @@ void resetPasswordUpdatesPasswordAndDeletesToken() { Assertions.assertTrue(response.updated()); User refreshedUser = refreshedUser(findUserId("user2@mnemosyne-systems.ai")); - Assertions.assertTrue(BcryptUtil.matches("new-pass", refreshedUser.passwordHash)); - Assertions.assertFalse(BcryptUtil.matches("user2", refreshedUser.passwordHash)); Assertions.assertNull(findToken(token)); } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/SuperuserAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/SuperuserAccessTest.java index e8ccb526..15bd5070 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/SuperuserAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/SuperuserAccessTest.java @@ -45,14 +45,20 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class SuperuserAccessTest extends AccessTestSupport { @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser1") }) void superuserCanAccessCompanyScopedPages() { - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); Entitlement createPageEntitlement = ensureEntitlement("Superuser Create Versions", "Superuser create version list"); ensureVersion(createPageEntitlement, "7.7.7", java.time.LocalDate.of(2024, 7, 1)); @@ -62,18 +68,15 @@ void superuserCanAccessCompanyScopedPages() { ai.mnemosyne_systems.model.Ticket superuserTicket = ensureTicket(companyId); ensureMessage(superuserTicket, "Superuser ticket message"); ai.mnemosyne_systems.model.Ticket otherCompanyTicket = ensureTicket(otherCompanyId); - String cookie = login("superuser1", "superuser1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/").then() - .statusCode(303).header("Location", "/"); + RestAssured.given().redirects().follow(false).get("/").then().statusCode(303).header("Location", "/"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/superuser").then() - .statusCode(303).header("Location", Matchers.endsWith("/superuser/tickets")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/superuser/tickets/create").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/superuser").then().statusCode(303).header("Location", + Matchers.endsWith("/superuser/tickets")); + RestAssured.given().redirects().follow(false).get("/superuser/tickets/create").then().statusCode(303) .header("Location", Matchers.endsWith("/superuser/tickets/new")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/superuser/tickets/bootstrap").then() - .statusCode(200).body("submitPath", Matchers.equalTo("/superuser/tickets")) + RestAssured.given().get("/api/superuser/tickets/bootstrap").then().statusCode(200) + .body("submitPath", Matchers.equalTo("/superuser/tickets")) .body("defaultAffectsVersion.name", Matchers.equalTo("1.0.0")); User creatingSuperuser = User.find("email", "superuser1@mnemosyne-systems.ai").firstResult(); CompanyEntitlement superuserCreateEntitlement = CompanyEntitlement @@ -84,8 +87,7 @@ void superuserCanAccessCompanyScopedPages() { .firstResult(); Assertions.assertNotNull(superuserCreateVersion); String superuserCreateMessage = "Superuser create redirect coverage"; - String superuserCreateRedirect = RestAssured.given().redirects().follow(false) - .cookie(AuthHelper.AUTH_COOKIE, cookie).multiPart("status", "Open") + String superuserCreateRedirect = RestAssured.given().redirects().follow(false).multiPart("status", "Open") .multiPart("title", "Superuser create redirect title").multiPart("message", superuserCreateMessage) .multiPart("companyId", companyId).multiPart("companyEntitlementId", superuserCreateEntitlement.id) .multiPart("categoryId", Category.findDefault().id) @@ -99,53 +101,52 @@ void superuserCanAccessCompanyScopedPages() { Assertions.assertEquals(superuserCreateVersion.id, createdSuperuserTicket.affectsVersion.id); Assertions.assertTrue(superuserCreateRedirect.endsWith("/superuser/tickets/" + createdSuperuserTicket.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/superuser/users/" + companyId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/superuser/users/" + companyId).then().statusCode(303) .header("Location", Matchers.endsWith("/superuser/users?companyId=" + companyId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("companyId", companyId) - .get("/api/superuser/users").then().statusCode(200).body("title", Matchers.equalTo("Users")) + RestAssured.given().queryParam("companyId", companyId).get("/api/superuser/users").then().statusCode(200) + .body("title", Matchers.equalTo("Users")) .body("selectedCompanyId", Matchers.equalTo(companyId.intValue())) .body("items.username", Matchers.hasItems("superuser1", "tam1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/reports/superuser") - .then().statusCode(303).header("Location", Matchers.endsWith("/reports")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/reports").then().statusCode(200) - .body("role", Matchers.equalTo("superuser")).body("showCompanyFilter", Matchers.equalTo(false)) - .body("selectedCompanyId", Matchers.notNullValue()).body("companyName", Matchers.not("All")); + RestAssured.given().redirects().follow(false).get("/reports/superuser").then().statusCode(303) + .header("Location", Matchers.endsWith("/reports")); + RestAssured.given().get("/api/reports").then().statusCode(200).body("role", Matchers.equalTo("superuser")) + .body("showCompanyFilter", Matchers.equalTo(false)).body("selectedCompanyId", Matchers.notNullValue()) + .body("companyName", Matchers.not("All")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/rss/superuser").then().statusCode(200) + RestAssured.given().get("/rss/superuser").then().statusCode(200) .contentType(Matchers.containsString("application/rss+xml")) .body(Matchers.containsString("Superuser tickets feed")); Long ticketId = superuserTicket == null ? null : superuserTicket.id; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/superuser/tickets/" + ticketId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/superuser/tickets/" + ticketId).then().statusCode(303) .header("Location", Matchers.endsWith("/superuser/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/superuser/tickets/" + ticketId).then() - .statusCode(200).body("title", Matchers.equalTo(superuserTicket.displayTitle())) + RestAssured.given().get("/api/superuser/tickets/" + ticketId).then().statusCode(200) + .body("title", Matchers.equalTo(superuserTicket.displayTitle())) .body("secondaryUsersLabel", Matchers.equalTo("Superusers")) .body("secondaryUsers.username", Matchers.hasItem("superuser1")) .body("supportUsers.username", Matchers.hasItem("support1")) .body("editableResolvedVersion", Matchers.equalTo(true)) .body("exportPath", Matchers.equalTo("/tickets/export/" + ticketId)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets/" + ticketId) - .then().statusCode(303).header("Location", Matchers.endsWith("/superuser/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100) - .get("/api/superuser/tickets").then().statusCode(200) + RestAssured.given().redirects().follow(false).get("/tickets/" + ticketId).then().statusCode(303) + .header("Location", Matchers.endsWith("/superuser/tickets/" + ticketId)); + RestAssured.given().queryParam("pageSize", 100).get("/api/superuser/tickets").then().statusCode(200) .body("items.name", Matchers.hasItem(superuserTicket.name)) .body("items.name", Matchers.not(Matchers.hasItem(otherCompanyTicket.name))); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/superuser/companies/" + otherCompanyId).then().statusCode(303) - .header("Location", Matchers.endsWith("/")); + RestAssured.given().redirects().follow(false).get("/superuser/companies/" + otherCompanyId).then() + .statusCode(303).header("Location", Matchers.endsWith("/")); } @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser1") }) void superuserReplyUsesLatestPendingMessageForTicketSlaColor() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); ensureDefaultCategories(); Long companyId = ensureCompany("Superuser SLA Co"); ensureCompanyUsers(companyId, "superuser1@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); @@ -155,72 +156,71 @@ void superuserReplyUsesLatestPendingMessageForTicketSlaColor() { ensureTimedMessage(ticket, "Initial superuser message", "superuser1@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(10)); - String cookie = login("superuser1", "superuser1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100) - .get("/api/superuser/tickets").then().statusCode(200) + RestAssured.given().queryParam("pageSize", 100).get("/api/superuser/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("Red")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", "Follow-up from superuser").post("/superuser/tickets/" + ticket.id + "/messages") - .then().statusCode(303); + RestAssured.given().redirects().follow(false).multiPart("body", "Follow-up from superuser") + .post("/superuser/tickets/" + ticket.id + "/messages").then().statusCode(303); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100) - .get("/api/superuser/tickets").then().statusCode(200) + RestAssured.given().queryParam("pageSize", 100).get("/api/superuser/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("White")); ensureTimedMessage(ticket, "Support follow-up", "support1@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(1)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100) - .get("/api/superuser/tickets").then().statusCode(200) + RestAssured.given().queryParam("pageSize", 100).get("/api/superuser/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("White")); } @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser1") }) void superuserTicketSearchMatchesVisibleMessagesOnly() { - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); Long companyId = ensureCompany("Superuser Search Co"); ensureCompanyUsers(companyId, "superuser1@mnemosyne-systems.ai"); Ticket visibleTicket = ensureTicket(companyId); String visibleBody = "superuser-visible-search-" + System.nanoTime(); ensureMessageWithBody(visibleTicket, visibleBody); - String cookie = login("superuser1", "superuser1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", visibleBody) - .get("/api/superuser/tickets").then().statusCode(200).body("searchTerm", Matchers.equalTo(visibleBody)) + RestAssured.given().queryParam("q", visibleBody).get("/api/superuser/tickets").then().statusCode(200) + .body("searchTerm", Matchers.equalTo(visibleBody)) .body("items.name", Matchers.hasItem(visibleTicket.name)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", "superuser-search-no-match") - .get("/api/superuser/tickets").then().statusCode(200).body("items.size()", Matchers.equalTo(0)); + RestAssured.given().queryParam("q", "superuser-search-no-match").get("/api/superuser/tickets").then() + .statusCode(200).body("items.size()", Matchers.equalTo(0)); } @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser1") }) void reactAppSessionReturnsSuperuserTicketLink() { - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); setInstallationUse24HourClock(false); - String cookie = login("superuser1", "superuser1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("role", Matchers.equalTo("superuser")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("role", Matchers.equalTo("superuser")) .body("navigation.href", Matchers.hasItem("/superuser/tickets")) .body("installationUse24HourClock", Matchers.equalTo(false)); } @Test + @TestSecurity(user = "superuser-json", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser-json@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser-json") }) void reactSuperuserMutationsReturnJsonRedirects() { - ensureUser("superuser-json", "superuser-json@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser-json"); + ensureUser("superuser-json", "superuser-json@mnemosyne-systems.ai", User.TYPE_SUPERUSER); ensureDefaultCategories(); Long companyId = ensureCompany("Superuser Json Co"); ensureCompanyUsers(companyId, "superuser-json@mnemosyne-systems.ai"); - String cookie = login("superuser-json", "superuser-json"); Entitlement entitlement = ensureEntitlement("Superuser Json Entitlement", "Superuser json detail"); Version version = ensureVersion(entitlement, "11.0.0", java.time.LocalDate.of(2026, 3, 1)); CompanyEntitlement entry = ensureCompanyEntitlement(companyId, entitlement); Assertions.assertNotNull(entry); Assertions.assertNotNull(version); - String redirectTo = RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie) - .header("X-Billetsys-Client", "react").multiPart("status", "Open") + String redirectTo = RestAssured.given().header("X-Billetsys-Client", "react").multiPart("status", "Open") .multiPart("title", "Superuser json redirect title") .multiPart("message", "Superuser json redirect create").multiPart("companyId", companyId) .multiPart("companyEntitlementId", entry.id).multiPart("categoryId", Category.findDefault().id) @@ -228,10 +228,10 @@ void reactSuperuserMutationsReturnJsonRedirects() { .body("redirectTo", Matchers.matchesPattern("/superuser/tickets/\\d+")).extract().path("redirectTo"); Long createdTicketId = Long.valueOf(redirectTo.substring(redirectTo.lastIndexOf('/') + 1)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("title", "Superuser json redirect title") - .formParam("affectsVersionId", version.id).post("/superuser/tickets/" + createdTicketId).then() - .statusCode(200).body("redirectTo", Matchers.equalTo("/superuser/tickets/" + createdTicketId)); + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("title", "Superuser json redirect title").formParam("affectsVersionId", version.id) + .post("/superuser/tickets/" + createdTicketId).then().statusCode(200) + .body("redirectTo", Matchers.equalTo("/superuser/tickets/" + createdTicketId)); } } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java index 49c6d226..e94ad9f1 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java @@ -23,8 +23,12 @@ import ai.mnemosyne_systems.model.Version; import ai.mnemosyne_systems.service.MailboxPollingService; import ai.mnemosyne_systems.service.TicketEmailService; +// [REMOVE] import ai.mnemosyne_systems.util.AuthHelper; -- AUTH_COOKIE no longer needed +// NOTE: AuthHelper.INACTIVITY_TIMEOUT_SECONDS and WARNING_LEAD_SECONDS are still +// referenced below as installation config constants (not auth); replace with +// their literal values or a shared constant if AuthHelper is fully removed. import ai.mnemosyne_systems.util.AuthHelper; -import io.quarkus.elytron.security.common.BcryptUtil; +// [REMOVE] import io.quarkus.elytron.security.common.BcryptUtil; -- Keycloak owns passwords import io.quarkus.hibernate.orm.panache.Panache; import io.quarkus.mailer.Mail; import io.quarkus.mailer.MockMailbox; @@ -46,33 +50,38 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +// [ADD] Keycloak JWT test security imports replacing cookie-based auth +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class SupportAccessTest extends AccessTestSupport { @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void supportCanAccessSupportUsersMenu() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("support2", "support2@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support2"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("support2", "support2@mnemosyne-systems.ai", User.TYPE_SUPPORT); ensureDefaultCategories(); Long companyId = ensureCompany("Support Co"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); ai.mnemosyne_systems.model.Ticket supportTicket = ensureTicket(companyId); ensureMessage(supportTicket, "Sample ticket created."); String supportTicketName = supportTicket == null ? "" : supportTicket.name; - String cookie = login("support1", "support1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/support/users/" + companyId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/support/users/" + companyId).then().statusCode(303) .header("Location", Matchers.endsWith("/support/users?companyId=" + companyId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("companyId", companyId) - .get("/api/support/users").then().statusCode(200).body("title", Matchers.equalTo("Users")) + RestAssured.given().queryParam("companyId", companyId).get("/api/support/users").then().statusCode(200) + .body("title", Matchers.equalTo("Users")) .body("selectedCompanyId", Matchers.equalTo(companyId.intValue())) .body("createPath", Matchers.equalTo("/support/users/new?companyId=" + companyId)) .body("items.username", Matchers.hasItem("tam1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/support").then() - .statusCode(303).header("Location", Matchers.endsWith("/support/tickets")); + RestAssured.given().redirects().follow(false).get("/support").then().statusCode(303).header("Location", + Matchers.endsWith("/support/tickets")); ai.mnemosyne_systems.model.User supportUser = ai.mnemosyne_systems.model.User .find("email", "support1@mnemosyne-systems.ai").firstResult(); int assignedCount = ai.mnemosyne_systems.model.Ticket.find( @@ -80,28 +89,25 @@ void supportCanAccessSupportUsersMenu() { supportUser).list().size(); int openCount = ai.mnemosyne_systems.model.Ticket .find("select distinct t from Ticket t where t.supportUsers is empty").list().size(); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100) - .get("/api/support/tickets").then().statusCode(200).body("title", Matchers.equalTo("Tickets")) - .body("assignedCount", Matchers.equalTo(assignedCount)).body("openCount", Matchers.equalTo(openCount)) - .body("items.name", Matchers.hasItem(supportTicketName)) + RestAssured.given().queryParam("pageSize", 100).get("/api/support/tickets").then().statusCode(200) + .body("title", Matchers.equalTo("Tickets")).body("assignedCount", Matchers.equalTo(assignedCount)) + .body("openCount", Matchers.equalTo(openCount)).body("items.name", Matchers.hasItem(supportTicketName)) .body("items.status", Matchers.hasItem("Assigned")) .body("items.supportUser.username", Matchers.hasItem("support1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/support/open").then() - .statusCode(303).header("Location", Matchers.endsWith("/support/tickets/open")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("view", "open") - .get("/api/support/tickets").then().statusCode(200).body("title", Matchers.equalTo("Open tickets")) + RestAssured.given().redirects().follow(false).get("/support/open").then().statusCode(303).header("Location", + Matchers.endsWith("/support/tickets/open")); + RestAssured.given().queryParam("view", "open").get("/api/support/tickets").then().statusCode(200) + .body("title", Matchers.equalTo("Open tickets")) .body("items.name", Matchers.hasItems("A-00002", "A-00003")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/support/closed") - .then().statusCode(303).header("Location", Matchers.endsWith("/support/tickets/closed")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("view", "closed") - .get("/api/support/tickets").then().statusCode(200).body("title", Matchers.equalTo("Closed tickets")) - .body("items.name", Matchers.hasItem("A-00004")); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/support/tickets/create").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/support/closed").then().statusCode(303).header("Location", + Matchers.endsWith("/support/tickets/closed")); + RestAssured.given().queryParam("view", "closed").get("/api/support/tickets").then().statusCode(200) + .body("title", Matchers.equalTo("Closed tickets")).body("items.name", Matchers.hasItem("A-00004")); + + RestAssured.given().redirects().follow(false).get("/support/tickets/create").then().statusCode(303) .header("Location", Matchers.endsWith("/support/tickets/new")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/bootstrap").then() - .statusCode(200).body("submitPath", Matchers.equalTo("/support/tickets")) + RestAssured.given().get("/api/support/tickets/bootstrap").then().statusCode(200) + .body("submitPath", Matchers.equalTo("/support/tickets")) .body("categories.name", Matchers.hasItem("Question")) .body("companyEntitlements.size()", Matchers.greaterThanOrEqualTo(1)) .body("ticketName", Matchers.not(Matchers.isEmptyOrNullString())); @@ -112,8 +118,7 @@ void supportCanAccessSupportUsersMenu() { .find("entitlement = ?1 order by date asc, id asc", supportCreateEntitlement.entitlement).firstResult(); Assertions.assertNotNull(supportCreateVersion); String supportCreateMessage = "Support create redirect coverage"; - String supportCreateRedirect = RestAssured.given().redirects().follow(false) - .cookie(AuthHelper.AUTH_COOKIE, cookie).multiPart("status", "Open") + String supportCreateRedirect = RestAssured.given().redirects().follow(false).multiPart("status", "Open") .multiPart("title", "Support create redirect title").multiPart("message", supportCreateMessage) .multiPart("companyId", companyId).multiPart("companyEntitlementId", supportCreateEntitlement.id) .multiPart("categoryId", Category.findDefault().id) @@ -128,92 +133,107 @@ void supportCanAccessSupportUsersMenu() { Assertions.assertTrue(supportCreateRedirect.endsWith("/support/tickets/" + createdSupportTicket.id)); Long ticketId = supportTicket == null ? null : supportTicket.id; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/tickets/" + ticketId + "/edit").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/tickets/" + ticketId + "/edit").then().statusCode(303) .header("Location", Matchers.endsWith("/tickets/" + ticketId + "/edit")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/ticket-workbench/" + ticketId).then() - .statusCode(200).body("entitlements.name", Matchers.hasItem(Matchers.containsString("Starter"))) + RestAssured.given().get("/api/ticket-workbench/" + ticketId).then().statusCode(200) + .body("entitlements.name", Matchers.hasItem(Matchers.containsString("Starter"))) .body("messages.body", Matchers.hasItem("Sample ticket created.")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets/1/edit") - .then().statusCode(303).header("Location", Matchers.endsWith("/tickets/1/edit")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/ticket-workbench/1").then().statusCode(200) - .body("ticket.externalIssueLink", - Matchers.equalTo("https://github.com/mnemosyne-systems/billetsys/issues/6")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie) - .queryParam("url", "https://github.com/mnemosyne-systems/billetsys/issues/6") + RestAssured.given().redirects().follow(false).get("/tickets/1/edit").then().statusCode(303).header("Location", + Matchers.endsWith("/tickets/1/edit")); + RestAssured.given().get("/api/ticket-workbench/1").then().statusCode(200).body("ticket.externalIssueLink", + Matchers.equalTo("https://github.com/mnemosyne-systems/billetsys/issues/6")); + RestAssured.given().queryParam("url", "https://github.com/mnemosyne-systems/billetsys/issues/6") .get("/tickets/external-preview").then().statusCode(200).body(Matchers .containsString("https://github.com/mnemosyne-systems/billetsys/issues/6")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/support/tickets/" + ticketId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/support/tickets/" + ticketId).then().statusCode(303) .header("Location", Matchers.endsWith("/support/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticketId).then() - .statusCode(200).body("messages.body", Matchers.hasItem("Sample ticket created.")) + RestAssured.given().get("/api/support/tickets/" + ticketId).then().statusCode(200) + .body("messages.body", Matchers.hasItem("Sample ticket created.")) .body("supportUsers.username", Matchers.hasItem("support1")) .body("tamUsers.username", Matchers.hasItem("tam1")).body("companyName", Matchers.equalTo("Support Co")) .body("entitlementName", Matchers.not(Matchers.isEmptyOrNullString())) .body("levelName", Matchers.equalTo("Normal")).body("categoryName", Matchers.nullValue()) .body("statusOptions", Matchers.hasItems("Open", "Assigned", "In Progress", "Resolved", "Closed")); ai.mnemosyne_systems.model.Ticket unassignedTicket = ensureUnassignedOpenTicket(companyId); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", "Auto assign on support reply") + RestAssured.given().redirects().follow(false).multiPart("body", "Auto assign on support reply") .post("/support/tickets/" + unassignedTicket.id + "/messages").then().statusCode(303); Ticket autoAssignedTicket = refreshedTicket(unassignedTicket.id); Assertions.assertEquals("Assigned", autoAssignedTicket.status); Assertions.assertTrue(ticketHasSupportUser(unassignedTicket.id, supportUser.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("title", supportTicket.displayTitle()) - .formParam("status", "Assigned").formParam("companyId", supportTicket.company.id) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", supportTicket.displayTitle()).formParam("status", "Assigned") + .formParam("companyId", supportTicket.company.id) .formParam("companyEntitlementId", supportTicket.companyEntitlement.id) .formParam("affectsVersionId", ensureVersion(supportTicket.companyEntitlement.entitlement, "1.0.0").id) .post("/support/tickets/" + ticketId).then().statusCode(303); Ticket updatedSupportTicket = refreshedTicket(ticketId); Assertions.assertEquals("Assigned", updatedSupportTicket.status); Assertions.assertTrue(ticketHasSupportUser(ticketId, supportUser.id)); - String userCookie = login("user", "user"); - String tamCookie = login("tam1", "tam1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie) - .get("/tickets/" + ticketId).then().statusCode(303) - .header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/user/tickets/" + ticketId).then() - .statusCode(200).body("displayStatus", Matchers.equalTo("Assigned")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie) - .get("/tickets/" + ticketId).then().statusCode(303) - .header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/api/user/tickets/" + ticketId).then() - .statusCode(200).body("displayStatus", Matchers.equalTo("Assigned")); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/profile").then() - .statusCode(303).header("Location", Matchers.endsWith("/profile")); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/profile/password") - .then().statusCode(303).header("Location", Matchers.endsWith("/profile/password")); - } - - @Test - void articlesRespectRolePermissions() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + + RestAssured.given().redirects().follow(false).get("/profile").then().statusCode(303).header("Location", + Matchers.endsWith("/profile")); + RestAssured.given().redirects().follow(false).get("/profile/password").then().statusCode(303).header("Location", + Matchers.endsWith("/profile/password")); + } + + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void userTicketRedirectWorksForUserRole() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + Long companyId = ensureCompany("Support Co User View"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + + RestAssured.given().redirects().follow(false).get("/tickets/" + ticket.id).then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/" + ticket.id)); + RestAssured.given().get("/api/user/tickets/" + ticket.id).then().statusCode(200).body("displayStatus", + Matchers.equalTo("Assigned")); + } + + @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) + void userTicketRedirectWorksForTamRole() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + Long companyId = ensureCompany("Support Co TAM View"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + + RestAssured.given().redirects().follow(false).get("/tickets/" + ticket.id).then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/" + ticket.id)); + RestAssured.given().get("/api/user/tickets/" + ticket.id).then().statusCode(200).body("displayStatus", + Matchers.equalTo("Assigned")); + } + + @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void supportCanCreateAndViewArticles() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); Article seeded = ensureArticle("Runbook", "ops,prod", "Seed article"); - String supportCookie = login("support1", "support1"); - String tamCookie = login("tam1", "tam1"); - String userCookie = login("user", "user"); - String adminCookie = login("admin", "admin"); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/articles") - .then().statusCode(303).header("Location", Matchers.endsWith("/articles")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .get("/articles/create").then().statusCode(303).header("Location", Matchers.endsWith("/articles/new")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .contentType("multipart/form-data").multiPart("title", "Article One").multiPart("tags", "guide") + + RestAssured.given().redirects().follow(false).get("/articles").then().statusCode(303).header("Location", + Matchers.endsWith("/articles")); + RestAssured.given().redirects().follow(false).get("/articles/create").then().statusCode(303).header("Location", + Matchers.endsWith("/articles/new")); + RestAssured.given().redirects().follow(false).contentType("multipart/form-data") + .multiPart("title", "Article One").multiPart("tags", "guide") .multiPart("body", "![pic](attachment://article.png)\n\n**Important**") .multiPart("attachments", "article.png", "img".getBytes(StandardCharsets.UTF_8), "image/png") .post("/articles").then().statusCode(303); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .contentType("multipart/form-data").multiPart("title", " article one ").multiPart("tags", "duplicate") + RestAssured.given().redirects().follow(false).contentType("multipart/form-data") + .multiPart("title", " article one ").multiPart("tags", "duplicate") .multiPart("body", "Duplicate body").post("/articles").then().statusCode(400) .body(Matchers.containsString("Article title already exists")); @@ -221,57 +241,88 @@ void articlesRespectRolePermissions() { Assertions.assertNotNull(created); Assertions.assertTrue(created.body.contains("/attachments/")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/articles/create") - .then().statusCode(303).header("Location", Matchers.endsWith("/articles/new")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie) - .get("/articles/" + created.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/articles/" + created.id).then().statusCode(303) .header("Location", Matchers.endsWith("/articles/" + created.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .get("/articles/" + created.id).then().statusCode(303) + } + + @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) + void tamCanCreateAndViewArticles() { + // [CHANGE] ensureUser calls — removed password argument + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + Article seeded = ensureArticle("Runbook TAM", "ops", "Seed article"); + + RestAssured.given().redirects().follow(false).get("/articles/create").then().statusCode(303).header("Location", + Matchers.endsWith("/articles/new")); + + // TAM can also update an existing article; duplicate title check applies + Article existing = ensureArticle("Article One TAM", "guide", "body"); + RestAssured.given().redirects().follow(false).contentType("multipart/form-data") + .multiPart("title", "Runbook TAM").multiPart("tags", "ops").multiPart("body", "Updated body") + .post("/articles/" + existing.id).then().statusCode(400) + .body(Matchers.containsString("Article title already exists")); + } + + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void userCanViewButNotCreateArticles() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + Article created = ensureArticle("Article For User", "guide", "body"); + + RestAssured.given().redirects().follow(false).get("/articles/" + created.id).then().statusCode(303) .header("Location", Matchers.endsWith("/articles/" + created.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/articles/create") - .then().statusCode(303); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie).get("/articles") - .then().statusCode(303).header("Location", Matchers.endsWith("/articles")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie) - .get("/articles/create").then().statusCode(303); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie) - .get("/articles/" + seeded.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/articles/create").then().statusCode(303); + } + + @Test + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) + void adminCanViewAndDeleteArticles() { + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + Article seeded = ensureArticle("Runbook Admin", "ops,prod", "Seed article"); + + RestAssured.given().redirects().follow(false).get("/articles").then().statusCode(303).header("Location", + Matchers.endsWith("/articles")); + RestAssured.given().redirects().follow(false).get("/articles/create").then().statusCode(303); + RestAssured.given().redirects().follow(false).get("/articles/" + seeded.id).then().statusCode(303) .header("Location", Matchers.endsWith("/articles/" + seeded.id)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie) - .contentType("multipart/form-data").multiPart("title", "Runbook").multiPart("tags", "ops") - .multiPart("body", "Updated body").post("/articles/" + created.id).then().statusCode(400) - .body(Matchers.containsString("Article title already exists")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie) - .post("/articles/" + seeded.id + "/delete").then().statusCode(303); + RestAssured.given().redirects().follow(false).post("/articles/" + seeded.id + "/delete").then().statusCode(303); Assertions.assertNull(refreshedArticle(seeded.id)); } @Test + @TestSecurity(user = "support3", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support3@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support-003") }) void supportCanManageTicketsAndMessages() { - ensureUser("support3", "support3@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support3"); + ensureUser("support3", "support3@mnemosyne-systems.ai", User.TYPE_SUPPORT); ensureDefaultCategories(); - String cookie = login("support3", "support3"); + Long companyId = ensureCompany("Support CRUD Co"); Company company = Company.findById(companyId); Entitlement entitlement = ensureEntitlement("Starter", "Email support"); Level level = ensureLevel("Normal", "Normal response level", 1440, "White"); CompanyEntitlement entry = ensureCompanyEntitlement(company, entitlement, level); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("status", "Open").formParam("title", "CRUD create title") - .formParam("companyId", company.id).formParam("companyEntitlementId", entry.id).post("/tickets").then() - .statusCode(303); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("status", "Open") + .formParam("title", "CRUD create title").formParam("companyId", company.id) + .formParam("companyEntitlementId", entry.id).post("/tickets").then().statusCode(303); Ticket ticket = Ticket.find("company = ?1 order by id desc", company).firstResult(); Assertions.assertNotNull(ticket); Assertions.assertNotNull(ticket.category); Assertions.assertEquals("Question", ticket.category.name); Category bugCategory = Category.find("name", "Bug").firstResult(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("status", "In Progress").formParam("companyId", company.id) - .formParam("companyEntitlementId", entry.id).formParam("categoryId", bugCategory.id) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("status", "In Progress") + .formParam("companyId", company.id).formParam("companyEntitlementId", entry.id) + .formParam("categoryId", bugCategory.id) .formParam("affectsVersionId", ensureVersion(entry.entitlement, "1.0.0").id) .formParam("externalIssueLink", "https://github.com/example/issue/1").post("/tickets/" + ticket.id) .then().statusCode(303); @@ -280,26 +331,23 @@ void supportCanManageTicketsAndMessages() { Assertions.assertNotNull(updatedTicket.category); Assertions.assertEquals("Bug", updatedTicket.category.name); Assertions.assertEquals("https://github.com/example/issue/1", updatedTicket.externalIssueLink); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets").then() - .statusCode(303).header("Location", Matchers.endsWith("/tickets")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/ticket-workbench").then().statusCode(200) - .body("items.name", Matchers.hasItem(ticket.name)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets/new").then() - .statusCode(303).header("Location", Matchers.endsWith("/tickets/new")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/ticket-workbench/bootstrap").then() - .statusCode(200).body("title", Matchers.equalTo("New ticket")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/tickets/" + ticket.id + "/edit").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/tickets").then().statusCode(303).header("Location", + Matchers.endsWith("/tickets")); + RestAssured.given().get("/api/ticket-workbench").then().statusCode(200).body("items.name", + Matchers.hasItem(ticket.name)); + RestAssured.given().redirects().follow(false).get("/tickets/new").then().statusCode(303).header("Location", + Matchers.endsWith("/tickets/new")); + RestAssured.given().get("/api/ticket-workbench/bootstrap").then().statusCode(200).body("title", + Matchers.equalTo("New ticket")); + RestAssured.given().redirects().follow(false).get("/tickets/" + ticket.id + "/edit").then().statusCode(303) .header("Location", Matchers.endsWith("/tickets/" + ticket.id + "/edit")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/ticket-workbench/" + ticket.id).then() - .statusCode(200) + RestAssured.given().get("/api/ticket-workbench/" + ticket.id).then().statusCode(200) .body("ticket.externalIssueLink", Matchers.equalTo("https://github.com/example/issue/1")); byte[] attachmentData = "Attachment line one\nAttachment line two".getBytes(StandardCharsets.UTF_8); byte[] attachmentDataTwo = "Second attachment".getBytes(StandardCharsets.UTF_8); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", "Support note").multiPart("date", "2024-01-01T10:00") - .multiPart("ticketId", String.valueOf(ticket.id)) + RestAssured.given().redirects().follow(false).multiPart("body", "Support note") + .multiPart("date", "2024-01-01T10:00").multiPart("ticketId", String.valueOf(ticket.id)) .multiPart("attachments", "note.txt", attachmentData, "text/plain") .multiPart("attachments", "note-2.txt", attachmentDataTwo, "text/plain") .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); @@ -315,24 +363,21 @@ void supportCanManageTicketsAndMessages() { Assertions.assertEquals("note-2.txt", secondAttachment.name); Assertions.assertEquals(attachmentDataTwo.length, secondAttachment.data.length); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticket.id).then() - .statusCode(200) + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) .body("messages.attachments.name.flatten()", Matchers.hasItems("note.txt", "note-2.txt")) .body("messages.attachments.mimeType.flatten()", Matchers.hasItem("text/plain")) .body("categoryName", Matchers.equalTo("Bug")) .body("externalIssueLink", Matchers.equalTo("https://github.com/example/issue/1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/attachments/" + attachment.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/attachments/" + attachment.id).then().statusCode(303) .header("Location", Matchers.endsWith("/attachments/" + attachment.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/attachments/" + attachment.id).then() - .statusCode(200).body("name", Matchers.equalTo("note.txt")) + RestAssured.given().get("/api/attachments/" + attachment.id).then().statusCode(200) + .body("name", Matchers.equalTo("note.txt")) .body("lines.content", Matchers.hasItem("Attachment line two")); byte[] replyData = "Reply attachment".getBytes(StandardCharsets.UTF_8); byte[] replyDataTwo = "Second reply attachment".getBytes(StandardCharsets.UTF_8); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", "Reply with attachments") + RestAssured.given().redirects().follow(false).multiPart("body", "Reply with attachments") .multiPart("attachments", "reply.txt", replyData, "text/plain") .multiPart("attachments", "reply-2.txt", replyDataTwo, "text/plain") .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); @@ -344,43 +389,44 @@ void supportCanManageTicketsAndMessages() { Assertions.assertEquals("reply.txt", replyAttachments.get(0).name); Assertions.assertEquals("reply-2.txt", replyAttachments.get(1).name); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .post("/tickets/" + ticket.id + "/delete").then().statusCode(303); + RestAssured.given().redirects().follow(false).post("/tickets/" + ticket.id + "/delete").then().statusCode(303); Assertions.assertNull(refreshedTicket(ticket.id)); } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void supportTicketSearchMatchesTicketNumberAndMessages() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); Long companyId = ensureCompany("Support Search Co"); Ticket ticket = ensureTicket(companyId); String messageBody = "support-search-message-" + System.nanoTime(); ensureMessageWithBody(ticket, messageBody); - String cookie = login("support1", "support1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", ticket.name) - .get("/api/support/tickets").then().statusCode(200).body("searchTerm", Matchers.equalTo(ticket.name)) - .body("items.name", Matchers.hasItem(ticket.name)); + RestAssured.given().queryParam("q", ticket.name).get("/api/support/tickets").then().statusCode(200) + .body("searchTerm", Matchers.equalTo(ticket.name)).body("items.name", Matchers.hasItem(ticket.name)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", messageBody) - .get("/api/support/tickets").then().statusCode(200).body("searchTerm", Matchers.equalTo(messageBody)) - .body("items.name", Matchers.hasItem(ticket.name)); + RestAssured.given().queryParam("q", messageBody).get("/api/support/tickets").then().statusCode(200) + .body("searchTerm", Matchers.equalTo(messageBody)).body("items.name", Matchers.hasItem(ticket.name)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", "no-support-ticket-match") - .get("/api/support/tickets").then().statusCode(200).body("items.size()", Matchers.equalTo(0)); + RestAssured.given().queryParam("q", "no-support-ticket-match").get("/api/support/tickets").then() + .statusCode(200).body("items.size()", Matchers.equalTo(0)); } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); ensureDefaultCategories(); Long companyId = ensureCompany("Support Detail Flow Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); ensureMessage(ticket, "Original support detail message"); - String cookie = login("support1", "support1"); Category bugCategory = Category.find("name", "Bug").firstResult(); Assertions.assertNotNull(bugCategory); @@ -390,9 +436,9 @@ void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { java.time.LocalDate.of(2024, 3, 1)); String externalIssueLink = "https://github.com/mnemosyne-systems/billetsys/issues/6"; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("title", ticket.displayTitle()) - .formParam("status", "Resolved").formParam("companyId", ticket.company.id) + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", ticket.displayTitle()).formParam("status", "Resolved") + .formParam("companyId", ticket.company.id) .formParam("companyEntitlementId", ticket.companyEntitlement.id).formParam("categoryId", bugCategory.id) .formParam("externalIssueLink", externalIssueLink).formParam("affectsVersionId", affectsVersion.id) .formParam("resolvedVersionId", resolvedVersion.id).post("/support/tickets/" + ticket.id).then() @@ -405,8 +451,8 @@ void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { Assertions.assertEquals(affectsVersion.id, updatedTicket.affectsVersion.id); Assertions.assertEquals(resolvedVersion.id, updatedTicket.resolvedVersion.id); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticket.id).then() - .statusCode(200).body("actionPath", Matchers.equalTo("/support/tickets/" + ticket.id)) + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) + .body("actionPath", Matchers.equalTo("/support/tickets/" + ticket.id)) .body("messageActionPath", Matchers.equalTo("/support/tickets/" + ticket.id + "/messages")) .body("displayStatus", Matchers.equalTo("Resolved")).body("categoryName", Matchers.equalTo("Bug")) .body("externalIssueLink", Matchers.equalTo(externalIssueLink)) @@ -415,9 +461,8 @@ void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { .body("messages.body", Matchers.hasItem("Original support detail message")); String plainReplyBody = "Support reply without attachments " + System.nanoTime(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", plainReplyBody).post("/support/tickets/" + ticket.id + "/messages").then() - .statusCode(303) + RestAssured.given().redirects().follow(false).multiPart("body", plainReplyBody) + .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303) .header("Location", Matchers.endsWith("/support/tickets/" + ticket.id + "?replyAdded=1")); Message plainReply = Message.find("ticket = ?1 and body = ?2", ticket, plainReplyBody).firstResult(); @@ -426,8 +471,7 @@ void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { byte[] replyAttachmentData = "Support detail attachment".getBytes(StandardCharsets.UTF_8); String attachmentReplyBody = "Support reply with attachments " + System.nanoTime(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", attachmentReplyBody) + RestAssured.given().redirects().follow(false).multiPart("body", attachmentReplyBody) .multiPart("attachments", "detail-reply.txt", replyAttachmentData, "text/plain") .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303) .header("Location", Matchers.endsWith("/support/tickets/" + ticket.id + "?replyAdded=1")); @@ -440,54 +484,84 @@ void supportTicketDetailUpdatesFieldsAndAddsRepliesWithAndWithoutAttachments() { Assertions.assertEquals("text/plain", replyAttachments.get(0).mimeType); Assertions.assertEquals(replyAttachmentData.length, replyAttachments.get(0).data.length); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticket.id).then() - .statusCode(200).body("messages.body", Matchers.hasItems(plainReplyBody, attachmentReplyBody)) + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) + .body("messages.body", Matchers.hasItems(plainReplyBody, attachmentReplyBody)) .body("messages.attachments.name.flatten()", Matchers.hasItem("detail-reply.txt")) .body("messages.attachments.mimeType.flatten()", Matchers.hasItem("text/plain")); } @Test - void attachmentsPageUsesRoleHeader() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - Long companyId = ensureCompany("Attachment Role Co"); + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void attachmentsPageUsesRoleHeaderForSupport() { + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + Long companyId = ensureCompany("Attachment Role Co Support"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); Message message = ensureMessageWithBody(ticket, "Attachment role message"); Attachment attachment = ensureAttachment(message, "role-attachment.txt"); - String supportCookie = login("support1", "support1"); User supportUser = User.find("email", "support1@mnemosyne-systems.ai").firstResult(); SupportResource.SupportTicketCounts counts = SupportResource.loadTicketCounts(supportUser); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .get("/attachments/" + attachment.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/attachments/" + attachment.id).then().statusCode(303) .header("Location", Matchers.endsWith("/attachments/" + attachment.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/api/attachments/" + attachment.id) - .then().statusCode(200).body("backPath", Matchers.equalTo("/support/tickets/" + ticket.id)); + RestAssured.given().get("/api/attachments/" + attachment.id).then().statusCode(200).body("backPath", + Matchers.equalTo("/support/tickets/" + ticket.id)); + } - String tamCookie = login("tam1", "tam1"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/api/attachments/" + attachment.id).then() - .statusCode(200).body("backPath", Matchers.equalTo("/user/tickets/" + ticket.id)); + @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) + void attachmentsPageUsesRoleHeaderForTam() { + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + Long companyId = ensureCompany("Attachment Role Co TAM"); + ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + Message message = ensureMessageWithBody(ticket, "Attachment role message tam"); + Attachment attachment = ensureAttachment(message, "role-attachment-tam.txt"); - String userCookie = login("user", "user"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/api/attachments/" + attachment.id).then() - .statusCode(200).body("backPath", Matchers.equalTo("/user/tickets/" + ticket.id)); + RestAssured.given().get("/api/attachments/" + attachment.id).then().statusCode(200).body("backPath", + Matchers.equalTo("/user/tickets/" + ticket.id)); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void attachmentsPageUsesRoleHeaderForUser() { + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + Long companyId = ensureCompany("Attachment Role Co User"); + ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); + Ticket ticket = ensureTicket(companyId); + Message message = ensureMessageWithBody(ticket, "Attachment role message user"); + Attachment attachment = ensureAttachment(message, "role-attachment-user.txt"); + + RestAssured.given().get("/api/attachments/" + attachment.id).then().statusCode(200).body("backPath", + Matchers.equalTo("/user/tickets/" + ticket.id)); + } + + @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void sendsEmailsForTicketMessageAndStatusChanges() { mailbox.clear(); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Email Notify Co"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai", "user@mnemosyne-systems.ai"); Ticket ticket = ensureUnassignedOpenTicket(companyId); - String supportCookie = login("support1", "support1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("body", "Email notification reply " + System.nanoTime()) + RestAssured.given().redirects().follow(false).multiPart("body", "Email notification reply " + System.nanoTime()) .multiPart("attachments", "notify.txt", "notify".getBytes(StandardCharsets.UTF_8), "text/plain") .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); @@ -498,8 +572,8 @@ void sendsEmailsForTicketMessageAndStatusChanges() { Assertions.assertTrue(firstMail.getText().contains("Message")); Assertions.assertEquals(1, firstMail.getAttachments().size()); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .contentType(ContentType.URLENC).formParam("title", ticket.displayTitle()).formParam("status", "Closed") + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC) + .formParam("title", ticket.displayTitle()).formParam("status", "Closed") .formParam("companyId", ticket.company.id) .formParam("companyEntitlementId", ticket.companyEntitlement.id) .formParam("affectsVersionId", ensureVersion(ticket.companyEntitlement.entitlement, "1.0.0").id) @@ -513,22 +587,24 @@ void sendsEmailsForTicketMessageAndStatusChanges() { } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void sendsTicketNotificationsUsingEachRecipientsPreferredEmailFormat() { mailbox.clear(); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); setUserEmailFormat("support1@mnemosyne-systems.ai", "html"); setUserEmailFormat("tam1@mnemosyne-systems.ai", "text"); setUserEmailFormat("user@mnemosyne-systems.ai", null); Long companyId = ensureCompany("Per Recipient Email Format Co"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai", "user@mnemosyne-systems.ai"); Ticket ticket = ensureUnassignedOpenTicket(companyId); - String supportCookie = login("support1", "support1"); - String body = "Per recipient email format " + System.nanoTime(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("body", body).post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); + String body = "Per recipient email format " + System.nanoTime(); + RestAssured.given().redirects().follow(false).multiPart("body", body) + .post("/support/tickets/" + ticket.id + "/messages").then().statusCode(303); Mail htmlOnlyMail = latestMailTo("support1@mnemosyne-systems.ai"); Assertions.assertNull(htmlOnlyMail.getText()); @@ -548,12 +624,14 @@ void sendsTicketNotificationsUsingEachRecipientsPreferredEmailFormat() { } @Test - void sendsCorrectEmailsForTicketCreationAcrossRoles() { + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void sendsCorrectEmailForSupportTicketCreation() { mailbox.clear(); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + setUserEmailFormat("support1@mnemosyne-systems.ai", null); ensureDefaultCategories(); Entitlement supportEntitlement = ensureEntitlement("Support Create Email Entitlement", @@ -563,9 +641,9 @@ void sendsCorrectEmailsForTicketCreationAcrossRoles() { ensureCompanyUsers(supportCompanyId, "tam1@mnemosyne-systems.ai"); CompanyEntitlement supportCompanyEntitlement = ensureCompanyEntitlement(supportCompanyId, supportEntitlement); String supportBody = "Support create mail body " + System.nanoTime(); - String supportCookie = login("support1", "support1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie) - .multiPart("status", "Open").multiPart("title", "Support mail title").multiPart("message", supportBody) + + RestAssured.given().redirects().follow(false).multiPart("status", "Open") + .multiPart("title", "Support mail title").multiPart("message", supportBody) .multiPart("companyId", supportCompanyId) .multiPart("companyEntitlementId", supportCompanyEntitlement.id) .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", supportVersion.id) @@ -573,6 +651,16 @@ void sendsCorrectEmailsForTicketCreationAcrossRoles() { Ticket createdSupportTicket = findMessageByBody(supportBody).ticket; assertTicketCreateMail(latestMailTo("support1@mnemosyne-systems.ai"), createdSupportTicket, "support1", "Open", supportBody); + } + + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void sendsCorrectEmailForUserTicketCreation() { + mailbox.clear(); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureDefaultCategories(); Entitlement userEntitlement = ensureEntitlement("User Create Email Entitlement", "User create email assertions"); @@ -581,14 +669,24 @@ void sendsCorrectEmailsForTicketCreationAcrossRoles() { ensureCompanyUsers(userCompanyId, "user@mnemosyne-systems.ai"); CompanyEntitlement userCompanyEntitlement = ensureCompanyEntitlement(userCompanyId, userEntitlement); String userBody = "User create mail body " + System.nanoTime(); - String userCookie = login("user", "user"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie) - .multiPart("status", "Open").multiPart("title", "User mail title").multiPart("message", userBody) - .multiPart("companyId", userCompanyId).multiPart("companyEntitlementId", userCompanyEntitlement.id) + + RestAssured.given().redirects().follow(false).multiPart("status", "Open").multiPart("title", "User mail title") + .multiPart("message", userBody).multiPart("companyId", userCompanyId) + .multiPart("companyEntitlementId", userCompanyEntitlement.id) .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", userVersion.id) .post("/user/tickets").then().statusCode(303); Ticket createdUserTicket = findMessageByBody(userBody).ticket; assertTicketCreateMail(latestMailTo("user@mnemosyne-systems.ai"), createdUserTicket, "user", "Open", userBody); + } + + @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser") }) + void sendsCorrectEmailForSuperuserTicketCreation() { + mailbox.clear(); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + ensureDefaultCategories(); Entitlement superuserEntitlement = ensureEntitlement("Superuser Create Email Entitlement", "Superuser create email assertions"); @@ -598,10 +696,10 @@ void sendsCorrectEmailsForTicketCreationAcrossRoles() { CompanyEntitlement superuserCompanyEntitlement = ensureCompanyEntitlement(superuserCompanyId, superuserEntitlement); String superuserBody = "Superuser create mail body " + System.nanoTime(); - String superuserCookie = login("superuser1", "superuser1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, superuserCookie) - .multiPart("status", "Open").multiPart("title", "Superuser mail title") - .multiPart("message", superuserBody).multiPart("companyId", superuserCompanyId) + + RestAssured.given().redirects().follow(false).multiPart("status", "Open") + .multiPart("title", "Superuser mail title").multiPart("message", superuserBody) + .multiPart("companyId", superuserCompanyId) .multiPart("companyEntitlementId", superuserCompanyEntitlement.id) .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", superuserVersion.id) .post("/superuser/tickets").then().statusCode(303); @@ -640,14 +738,16 @@ void reactAppSessionReturnsGuestStateWhenSignedOut() { } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void reactAppSessionReturnsRoleAwareLinksWhenSignedIn() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); setInstallationUse24HourClock(false); - String cookie = login("user", "user"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("authenticated", Matchers.equalTo(true)).body("username", Matchers.equalTo("user")) - .body("role", Matchers.equalTo("user")).body("homePath", Matchers.equalTo("/user/tickets")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("authenticated", Matchers.equalTo(true)) + .body("username", Matchers.equalTo("user")).body("role", Matchers.equalTo("user")) + .body("homePath", Matchers.equalTo("/user/tickets")) .body("installationLogoBase64", Matchers.startsWith("data:image/svg+xml;base64,")) .body("installationBackgroundBase64", Matchers.anyOf(Matchers.nullValue(), Matchers.startsWith("data:image/"))) @@ -664,31 +764,24 @@ void reactAppSessionReturnsRoleAwareLinksWhenSignedIn() { } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void reactAppSessionReturnsSupportTicketLink() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - String cookie = login("support1", "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/app/session").then().statusCode(200) - .body("role", Matchers.equalTo("support")).body("homePath", Matchers.equalTo("/support/tickets")) + RestAssured.given().get("/api/app/session").then().statusCode(200).body("role", Matchers.equalTo("support")) + .body("homePath", Matchers.equalTo("/support/tickets")) .body("navigation.href", Matchers.hasItem("/support/tickets")) .body("inactivityTimeoutSeconds", Matchers.equalTo(AuthHelper.INACTIVITY_TIMEOUT_SECONDS)) .body("inactivityWarningSeconds", Matchers.equalTo(AuthHelper.WARNING_LEAD_SECONDS)); } - @Test - void reactLoginRedirectsSupportToSupportTickets() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - - RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("username", "support1") - .formParam("password", "support1").post("/login").then().statusCode(303) - .header("Location", Matchers.equalTo("/support/tickets")); - } - @Test void incomingEmailWithTicketSubjectAddsMessageAndAttachments() { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); Long companyId = ensureCompany("Incoming Existing Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); @@ -713,7 +806,7 @@ void incomingEmailWithTicketSubjectAddsMessageAndAttachments() { @Test void incomingEmailWithoutTicketSubjectCreatesTicket() { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Incoming New Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai"); ensureTicket(companyId); @@ -733,9 +826,9 @@ void incomingEmailWithoutTicketSubjectCreatesTicket() { @Test void incomingEmailIgnoresUnknownOrMismatchedFrom() { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("other", "other@mnemosyne-systems.ai", User.TYPE_USER, "other"); - ensureUser("orphan", "orphan@mnemosyne-systems.ai", User.TYPE_USER, "orphan"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("other", "other@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("orphan", "orphan@mnemosyne-systems.ai", User.TYPE_USER); Long companyA = ensureCompany("Incoming Match Co"); Long companyB = ensureCompany("Incoming Other Co"); ensureCompanyUsers(companyA, "user@mnemosyne-systems.ai"); @@ -774,8 +867,8 @@ void incomingEmailRequiresBody() { @Test void incomingEmailNormalizesSenderAndAssignsCompanyTamsOnNewTicket() { mailbox.clear(); - ensureUser("normalize-user", "normalize-user@mnemosyne-systems.ai", User.TYPE_USER, "normalize-user"); - ensureUser("normalize-tam", "normalize-tam@mnemosyne-systems.ai", User.TYPE_TAM, "normalize-tam"); + ensureUser("normalize-user", "normalize-user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("normalize-tam", "normalize-tam@mnemosyne-systems.ai", User.TYPE_TAM); Long companyId = ensureCompany("Incoming Normalize Co"); ensureCompanyUsers(companyId, "normalize-user@mnemosyne-systems.ai", "normalize-tam@mnemosyne-systems.ai"); ensureTicket(companyId); @@ -797,8 +890,8 @@ void incomingEmailNormalizesSenderAndAssignsCompanyTamsOnNewTicket() { @Test void mailboxMessageWithTicketSubjectAddsMessageAndAttachments() throws Exception { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); Long companyId = ensureCompany("Mailbox Existing Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); @@ -836,7 +929,7 @@ void mailboxMessageWithTicketSubjectAddsMessageAndAttachments() throws Exception @Test void mailboxMessageWithoutTicketSubjectCreatesTicket() throws Exception { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Mailbox New Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai"); ensureTicket(companyId); @@ -860,7 +953,7 @@ void mailboxMessageWithoutTicketSubjectCreatesTicket() throws Exception { @Test void mailboxHtmlMessageStripsHtmlAndKeepsInlineAttachments() throws Exception { mailbox.clear(); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("Mailbox Html Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai"); ensureTicket(companyId); @@ -908,36 +1001,38 @@ void computeEffectiveStatusUsesSupportAssignmentsAndOpenFallback() { } @Test + @TestSecurity(user = "support-json", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support-json@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support-002") }) void reactSupportMutationsReturnJsonRedirects() { - ensureUser("support-json", "support-json@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support-json"); + ensureUser("support-json", "support-json@mnemosyne-systems.ai", User.TYPE_SUPPORT); ensureDefaultCategories(); - String cookie = login("support-json", "support-json"); + Long companyId = ensureCompany("Support Json Co"); Company company = Company.findById(companyId); Entitlement entitlement = ensureEntitlement("Support Json Entitlement", "Support json detail"); Version version = ensureVersion(entitlement, "8.0.0", java.time.LocalDate.of(2026, 1, 1)); CompanyEntitlement entry = ensureCompanyEntitlement(companyId, entitlement); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .multiPart("status", "Open").multiPart("title", "Support json redirect title") - .multiPart("message", "Support json redirect create").multiPart("companyId", company.id) - .multiPart("companyEntitlementId", entry.id).multiPart("categoryId", Category.findDefault().id) - .multiPart("affectsVersionId", version.id).post("/support/tickets").then().statusCode(200) + RestAssured.given().header("X-Billetsys-Client", "react").multiPart("status", "Open") + .multiPart("title", "Support json redirect title").multiPart("message", "Support json redirect create") + .multiPart("companyId", company.id).multiPart("companyEntitlementId", entry.id) + .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", version.id) + .post("/support/tickets").then().statusCode(200) .body("redirectTo", Matchers.matchesPattern("/support/tickets/\\d+")); Ticket createdTicket = findMessageByBody("Support json redirect create").ticket; Assertions.assertNotNull(createdTicket); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("title", createdTicket.displayTitle()) - .formParam("status", "Assigned").formParam("companyId", company.id) - .formParam("companyEntitlementId", entry.id).formParam("categoryId", Category.findDefault().id) - .formParam("affectsVersionId", version.id).post("/support/tickets/" + createdTicket.id).then() - .statusCode(200).body("redirectTo", Matchers.equalTo("/support/tickets/" + createdTicket.id)); + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("title", createdTicket.displayTitle()).formParam("status", "Assigned") + .formParam("companyId", company.id).formParam("companyEntitlementId", entry.id) + .formParam("categoryId", Category.findDefault().id).formParam("affectsVersionId", version.id) + .post("/support/tickets/" + createdTicket.id).then().statusCode(200) + .body("redirectTo", Matchers.equalTo("/support/tickets/" + createdTicket.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") + RestAssured.given().header("X-Billetsys-Client", "react") .post("/support/tickets/" + createdTicket.id + "/assign").then().statusCode(200) .body("redirectTo", Matchers.equalTo("/support/tickets/" + createdTicket.id)); } - } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/TAMAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/TAMAccessTest.java index 6b49d761..0be6d38b 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/TAMAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/TAMAccessTest.java @@ -45,90 +45,88 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class TAMAccessTest extends AccessTestSupport { @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam1") }) void tamCanAccessUserTicketsMenu() { - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); Entitlement createPageEntitlement = ensureEntitlement("TAM Create Versions", "TAM create version list"); ensureVersion(createPageEntitlement, "8.8.8", java.time.LocalDate.of(2024, 6, 1)); Long companyId = ensureCompany("TAM Co"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); - ai.mnemosyne_systems.model.Ticket tamTicket = ensureTicket(companyId); + Ticket tamTicket = ensureTicket(companyId); String tamTicketName = tamTicket == null ? null : tamTicket.name; - String cookie = login("tam1", "tam1"); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/tickets").then() - .statusCode(303).header("Location", Matchers.endsWith("/user/tickets")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets").then().statusCode(200) - .body("items.supportUser.username", Matchers.hasItem("support1")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets/bootstrap").then() - .statusCode(200).body("submitPath", Matchers.equalTo("/user/tickets")) + + RestAssured.given().redirects().follow(false).get("/user/tickets").then().statusCode(303).header("Location", + Matchers.endsWith("/user/tickets")); + RestAssured.given().get("/api/user/tickets").then().statusCode(200).body("items.supportUser.username", + Matchers.hasItem("support1")); + RestAssured.given().get("/api/user/tickets/bootstrap").then().statusCode(200) + .body("submitPath", Matchers.equalTo("/user/tickets")) .body("defaultAffectsVersion.name", Matchers.equalTo("1.0.0")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/rss/tam").then().statusCode(200) + RestAssured.given().get("/rss/tam").then().statusCode(200) .contentType(Matchers.containsString("application/rss+xml")).body(Matchers.containsString(" entry.id.equals(tamCreated.id))); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie) - .contentType(ContentType.URLENC).formParam("name", "TAM User") + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("name", "TAM User") .formParam("email", "tam-user-duplicate@mnemosyne-systems.ai").formParam("password", "tam-user") .formParam("type", "user").formParam("companyId", tamCompanyId).post("/tam/users").then() .statusCode(400); } @Test - void reportsRespectRolePermissions() { - ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "admin"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); + @TestSecurity(user = "admin", roles = "admin") + @JwtSecurity(claims = { @Claim(key = "email", value = "admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "admin") }) + void adminCanAccessReports() { + // [CHANGE] ensureUser — removed password argument + ensureUser("admin", "admin@mnemosyne-systems.ai", User.TYPE_ADMIN); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); Long companyId = ensureCompany("Report Co"); ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); ensureTicket(companyId); - String adminCookie = login("admin", "admin"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie).get("/reports").then() - .statusCode(303).header("Location", Matchers.endsWith("/reports")); + RestAssured.given().redirects().follow(false).get("/reports").then().statusCode(303).header("Location", + Matchers.endsWith("/reports")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, adminCookie) - .queryParam("companyId", companyId).get("/reports").then().statusCode(303) - .header("Location", Matchers.endsWith("/reports?companyId=" + companyId)); + RestAssured.given().redirects().follow(false).queryParam("companyId", companyId).get("/reports").then() + .statusCode(303).header("Location", Matchers.endsWith("/reports?companyId=" + companyId)); + } - String tamCookie = login("tam1", "tam1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/reports/tam") - .then().statusCode(303).header("Location", Matchers.endsWith("/reports")); + @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) + void tamCanAccessReports() { + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + Long companyId = ensureCompany("Report Co TAM"); + ensureCompanyUsers(companyId, "tam1@mnemosyne-systems.ai"); + ensureTicket(companyId); - String userCookie = login("user", "user"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/reports").then() - .statusCode(303); + RestAssured.given().redirects().follow(false).get("/reports/tam").then().statusCode(303).header("Location", + Matchers.endsWith("/reports")); + } - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, userCookie).get("/reports/tam") - .then().statusCode(303); + @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) + void userCannotAccessReports() { + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + Long companyId = ensureCompany("Report Co User"); + ensureTicket(companyId); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, tamCookie).get("/reports").then() - .statusCode(303); + RestAssured.given().redirects().follow(false).get("/reports").then().statusCode(403); + + RestAssured.given().redirects().follow(false).get("/reports/tam").then().statusCode(403); + } + + @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) + void supportCannotAccessReports() { + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + Long companyId = ensureCompany("Report Co Support"); + ensureTicket(companyId); - String supportCookie = login("support1", "support1"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, supportCookie).get("/reports") - .then().statusCode(303); + RestAssured.given().redirects().follow(false).get("/reports").then().statusCode(403); } } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/TicketImportResourceTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketImportResourceTest.java index ed32f045..3890810e 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/TicketImportResourceTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketImportResourceTest.java @@ -24,14 +24,19 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class TicketImportResourceTest extends AccessTestSupport { @Test + @TestSecurity(user = "import-support", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-support@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-support") }) void supportUserCanImportCsvTicketsAndMessages() { ImportFixture fixture = fixture("import-success"); - String cookie = login("import-support", "pass"); String sourceKey = "csv-success-" + System.nanoTime(); String csv = """ source_key,title,company,entitlement,status,initial_message,requester_email,category,external_issue_link,created_at @@ -40,7 +45,7 @@ void supportUserCanImportCsvTicketsAndMessages() { """ .formatted(sourceKey, fixture.companyName(), fixture.entitlementName()); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("rowCount", Matchers.equalTo(1)) .body("createdCount", Matchers.equalTo(1)).body("skippedCount", Matchers.equalTo(0)) @@ -57,17 +62,19 @@ void supportUserCanImportCsvTicketsAndMessages() { } @Test + @TestSecurity(user = "import-admin", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-admin@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-admin") }) void adminUserCanImportCsvTicketsAndMessages() { ImportFixture fixture = fixture("import-admin-success"); - ensureUser("import-admin", "import-admin@mnemosyne-systems.ai", User.TYPE_ADMIN, "pass"); - String cookie = login("import-admin", "pass"); + ensureUser("import-admin", "import-admin@mnemosyne-systems.ai", User.TYPE_ADMIN); String sourceKey = "csv-admin-success-" + System.nanoTime(); String csv = """ source_key,title,company,entitlement,status,initial_message,requester_email,category %s,Imported by admin,%s,%s,Open,Imported from admin,import-user@mnemosyne-systems.ai,Bug """.formatted(sourceKey, fixture.companyName(), fixture.entitlementName()); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("rowCount", Matchers.equalTo(1)) .body("createdCount", Matchers.equalTo(1)).body("failedCount", Matchers.equalTo(0)); @@ -78,19 +85,21 @@ void adminUserCanImportCsvTicketsAndMessages() { } @Test + @TestSecurity(user = "import-support", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-support@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-support") }) void duplicateSourceKeyIsSkippedOnReimport() { ImportFixture fixture = fixture("import-duplicate"); - String cookie = login("import-support", "pass"); String sourceKey = "csv-duplicate-" + System.nanoTime(); String csv = """ source_key,title,company,entitlement,status,initial_message %s,Duplicate import,%s,%s,Open,Initial duplicate import """.formatted(sourceKey, fixture.companyName(), fixture.entitlementName()); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("createdCount", Matchers.equalTo(1)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("createdCount", Matchers.equalTo(0)) .body("skippedCount", Matchers.equalTo(1)).body("results[0].result", Matchers.equalTo("skipped")); @@ -100,16 +109,18 @@ void duplicateSourceKeyIsSkippedOnReimport() { } @Test + @TestSecurity(user = "import-support", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-support@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-support") }) void invalidRowsReturnRowLevelErrors() { fixture("import-invalid"); - String cookie = login("import-support", "pass"); String sourceKey = "csv-invalid-" + System.nanoTime(); String csv = """ source_key,title,company,entitlement,status,initial_message,category %s,Missing company,Does Not Exist,Starter,Open,Message,Bug """.formatted(sourceKey); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("createdCount", Matchers.equalTo(0)) .body("failedCount", Matchers.equalTo(1)).body("results[0].result", Matchers.equalTo("failed")) @@ -119,15 +130,17 @@ void invalidRowsReturnRowLevelErrors() { } @Test + @TestSecurity(user = "import-support", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-support@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-support") }) void missingSourceKeyReturnsRowLevelError() { ImportFixture fixture = fixture("import-missing-source-key"); - String cookie = login("import-support", "pass"); String csv = """ source_key,title,company,entitlement,status,initial_message ,Missing source key,%s,%s,Open,Message """.formatted(fixture.companyName(), fixture.entitlementName()); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(200).body("createdCount", Matchers.equalTo(0)) .body("failedCount", Matchers.equalTo(1)).body("results[0].result", Matchers.equalTo("failed")) @@ -135,37 +148,40 @@ void missingSourceKeyReturnsRowLevelError() { } @Test + @TestSecurity(user = "import-user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-user") }) void nonSupportUserCannotImportCsv() { fixture("import-access"); - String cookie = login("import-user", "pass"); String csv = """ source_key,title,company,entitlement,status,initial_message cannot-import,Denied,Company,Starter,Open,Denied """; - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType("multipart/form-data") + RestAssured.given().redirects().follow(false).contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") - .post("/api/ticket-imports/csv").then().statusCode(303); + .post("/api/ticket-imports/csv").then().statusCode(403); } @Test + @TestSecurity(user = "import-support", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "import-support@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "import-support") }) void missingRequiredHeaderFailsClearly() { - ensureUser("import-support", "import-support@mnemosyne-systems.ai", User.TYPE_SUPPORT, "pass"); - String cookie = login("import-support", "pass"); + ensureUser("import-support", "import-support@mnemosyne-systems.ai", User.TYPE_SUPPORT); String csv = """ source_key,title,company,status,initial_message missing-entitlement,Missing entitlement,Company,Open,Message """; - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType("multipart/form-data") + RestAssured.given().contentType("multipart/form-data") .multiPart("file", "tickets.csv", csv.getBytes(StandardCharsets.UTF_8), "text/csv") .post("/api/ticket-imports/csv").then().statusCode(400).body(Matchers.containsString("entitlement")); } private ImportFixture fixture(String suffix) { - ensureUser("import-support", "import-support@mnemosyne-systems.ai", User.TYPE_SUPPORT, "pass"); - ensureUser("import-user", "import-user@mnemosyne-systems.ai", User.TYPE_USER, "pass"); + ensureUser("import-support", "import-support@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("import-user", "import-user@mnemosyne-systems.ai", User.TYPE_USER); ensureCategory("Bug", "Defects", false); Entitlement entitlement = ensureEntitlement("Starter Import " + suffix, "Import entitlement"); Long companyId = ensureCompany("Import Company " + suffix); diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java index 0f56a1ac..d0c3bb84 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/TicketRatingAccessTest.java @@ -1,10 +1,10 @@ /* - * Eclipse Public License - v 2.0 - * - * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE - * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION - * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. - */ +* Eclipse Public License - v 2.0 +* +* THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE +* PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION +* OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. +*/ package ai.mnemosyne_systems.resource; @@ -17,26 +17,28 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class TicketRatingAccessTest extends AccessTestSupport { - @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void userCanRateOwnResolvedTicket() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating User Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("rating", 8).formParam("ratingComment", "Great support!") - .post("/tickets/" + ticket.id + "/rating").then().statusCode(200) - .body("redirectTo", Matchers.equalTo("")); + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC).formParam("rating", 8) + .formParam("ratingComment", "Great support!").post("/tickets/" + ticket.id + "/rating").then() + .statusCode(200).body("redirectTo", Matchers.equalTo("")); Ticket rated = refreshedTicket(ticket.id); Assertions.assertEquals(8, rated.rating); @@ -44,21 +46,22 @@ void userCanRateOwnResolvedTicket() { } @Test + @TestSecurity(user = "superuser1", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "superuser1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser") }) void superuserCanRateCompanyTicket() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "superuser1"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("superuser1", "superuser1@mnemosyne-systems.ai", User.TYPE_SUPERUSER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Superuser Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("superuser1", "superuser1"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("rating", 10).formParam("ratingComment", "Excellent!") + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("rating", 10).formParam("ratingComment", "Excellent!") .post("/tickets/" + ticket.id + "/rating").then().statusCode(200) .body("redirectTo", Matchers.equalTo("")); @@ -68,46 +71,48 @@ void superuserCanRateCompanyTicket() { } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support") }) void supportCannotSubmitRating() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Support Deny Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("support1", "support1"); - - // Support role should be redirected to login (role not allowed) - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("rating", 7).post("/tickets/" + ticket.id + "/rating").then() - .statusCode(303).header("Location", Matchers.endsWith("/login")); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("rating", 7) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(403); } @Test + @TestSecurity(user = "tam1", roles = "tam") + @JwtSecurity(claims = { @Claim(key = "email", value = "tam1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "tam") }) void tamCannotSubmitRating() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating TAM Deny Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("tam1", "tam1"); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("rating", 7).post("/tickets/" + ticket.id + "/rating").then() - .statusCode(303).header("Location", Matchers.endsWith("/login")); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("rating", 7) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(403); } @Test + @TestSecurity(user = "other-su", roles = "superuser") + @JwtSecurity(claims = { @Claim(key = "email", value = "other-su@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "superuser") }) void superuserCannotRateOtherCompanyTicket() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); - ensureUser("other-su", "other-su@mnemosyne-systems.ai", User.TYPE_SUPERUSER, "other-su"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); + ensureUser("other-su", "other-su@mnemosyne-systems.ai", User.TYPE_SUPERUSER); ensureDefaultCategories(); Long companyA = ensureCompany("Rating Company A"); ensureCompanyUsers(companyA, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); @@ -115,78 +120,79 @@ void superuserCannotRateOtherCompanyTicket() { ensureCompanyUsers(companyB, "other-su@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyA); - // Superuser from Company B tries to rate Company A's ticket - String cookie = login("other-su", "other-su"); - - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .contentType(ContentType.URLENC).formParam("rating", 5).post("/tickets/" + ticket.id + "/rating").then() - .statusCode(303).header("Location", Matchers.endsWith("/")); + RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("rating", 5) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(303) + .header("Location", Matchers.endsWith("/")); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void ratingBelowOneIsRejected() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Range Low Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) - .formParam("rating", 0).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + RestAssured.given().contentType(ContentType.URLENC).formParam("rating", 0) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(400); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void ratingAboveTenIsRejected() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Range High Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) - .formParam("rating", 11).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + RestAssured.given().contentType(ContentType.URLENC).formParam("rating", 11) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(400); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void cannotRateNonResolvedTicket() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Not Resolved Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureTicket(companyId); // status is "Assigned", not "Resolved" - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) - .formParam("rating", 5).post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + RestAssured.given().contentType(ContentType.URLENC).formParam("rating", 5) + .post("/tickets/" + ticket.id + "/rating").then().statusCode(400); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void cannotRateAlreadyRatedTicket() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Already Rated Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); setTicketRating(ticket.id, 7, "Already rated"); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).contentType(ContentType.URLENC) - .formParam("rating", 9).formParam("ratingComment", "Try again") - .post("/tickets/" + ticket.id + "/rating").then().statusCode(400); + RestAssured.given().contentType(ContentType.URLENC).formParam("rating", 9) + .formParam("ratingComment", "Try again").post("/tickets/" + ticket.id + "/rating").then() + .statusCode(400); // Verify original rating is unchanged Ticket unchanged = refreshedTicket(ticket.id); @@ -195,20 +201,20 @@ void cannotRateAlreadyRatedTicket() { } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void ratingWithoutCommentSetsCommentToNull() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating No Comment Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("rating", 5).formParam("ratingComment", "") - .post("/tickets/" + ticket.id + "/rating").then().statusCode(200); + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC).formParam("rating", 5) + .formParam("ratingComment", "").post("/tickets/" + ticket.id + "/rating").then().statusCode(200); Ticket rated = refreshedTicket(ticket.id); Assertions.assertEquals(5, rated.rating); @@ -216,54 +222,52 @@ void ratingWithoutCommentSetsCommentToNull() { } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void ratingIsVisibleInUserTicketDetailResponse() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Visible User Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); setTicketRating(ticket.id, 9, "Wonderful service"); - String cookie = login("user", "user"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets/" + ticket.id).then() - .statusCode(200).body("rating", Matchers.equalTo(9)) - .body("ratingComment", Matchers.equalTo("Wonderful service")); + RestAssured.given().get("/api/user/tickets/" + ticket.id).then().statusCode(200) + .body("rating", Matchers.equalTo(9)).body("ratingComment", Matchers.equalTo("Wonderful service")); } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support1") }) void ratingIsVisibleInSupportTicketDetailResponse() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Visible Support Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); setTicketRating(ticket.id, 4, "Could be better"); - String cookie = login("support1", "support1"); - - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/support/tickets/" + ticket.id).then() - .statusCode(200).body("rating", Matchers.equalTo(4)) - .body("ratingComment", Matchers.equalTo("Could be better")); + RestAssured.given().get("/api/support/tickets/" + ticket.id).then().statusCode(200) + .body("rating", Matchers.equalTo(4)).body("ratingComment", Matchers.equalTo("Could be better")); } @Test void unauthenticatedUserCannotRate() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("Rating Unauth Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); Ticket ticket = ensureResolvedTicket(companyId); - // No cookie — should redirect to login RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("rating", 5) - .post("/tickets/" + ticket.id + "/rating").then().statusCode(303) - .header("Location", Matchers.endsWith("/login")); + .post("/tickets/" + ticket.id + "/rating").then().statusCode(401); } } diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/UserAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/UserAccessTest.java index 57e7be19..dad5ebd6 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/UserAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/UserAccessTest.java @@ -54,13 +54,19 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import io.quarkus.test.security.TestSecurity; +import io.quarkus.test.security.jwt.Claim; +import io.quarkus.test.security.jwt.JwtSecurity; @QuarkusTest class UserAccessTest extends AccessTestSupport { @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void userCanAccessUserTicketsMenu() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); ensureDefaultCategories(); Entitlement createPageEntitlement = ensureEntitlement("Create Page Versions", "Create page version list"); ensureVersion(createPageEntitlement, "0.9.0", java.time.LocalDate.of(2024, 1, 1)); @@ -69,22 +75,20 @@ void userCanAccessUserTicketsMenu() { ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "superuser1@mnemosyne-systems.ai"); ai.mnemosyne_systems.model.Ticket userTicket = ensureTicket(companyId); String userTicketName = userTicket == null ? "" : userTicket.name; - String cookie = login("user", "user"); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/tickets").then() - .statusCode(303).header("Location", Matchers.endsWith("/user/tickets")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/tickets/open") - .then().statusCode(303).header("Location", Matchers.endsWith("/user/tickets/open")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/tickets/closed") - .then().statusCode(303).header("Location", Matchers.endsWith("/user/tickets/closed")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/user/tickets/create") - .then().statusCode(303).header("Location", Matchers.endsWith("/user/tickets/new")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("pageSize", 100).get("/api/user/tickets") - .then().statusCode(200).body("title", Matchers.equalTo("Tickets")) - .body("createPath", Matchers.equalTo("/user/tickets/new")) + RestAssured.given().redirects().follow(false).get("/user/tickets").then().statusCode(303).header("Location", + Matchers.endsWith("/user/tickets")); + RestAssured.given().redirects().follow(false).get("/user/tickets/open").then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/open")); + RestAssured.given().redirects().follow(false).get("/user/tickets/closed").then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/closed")); + RestAssured.given().redirects().follow(false).get("/user/tickets/create").then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/new")); + RestAssured.given().queryParam("pageSize", 100).get("/api/user/tickets").then().statusCode(200) + .body("title", Matchers.equalTo("Tickets")).body("createPath", Matchers.equalTo("/user/tickets/new")) .body("items.name", Matchers.hasItem(userTicketName)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets/bootstrap").then() - .statusCode(200).body("submitPath", Matchers.equalTo("/user/tickets")) + RestAssured.given().get("/api/user/tickets/bootstrap").then().statusCode(200) + .body("submitPath", Matchers.equalTo("/user/tickets")) .body("defaultAffectsVersion.name", Matchers.equalTo("1.0.0")) .body("categories.name", Matchers.hasItem("Question")); User creatingUser = User.find("email", "user@mnemosyne-systems.ai").firstResult(); @@ -95,10 +99,9 @@ void userCanAccessUserTicketsMenu() { .find("entitlement = ?1 order by date asc, id asc", userCreateEntitlement.entitlement).firstResult(); Assertions.assertNotNull(userCreateVersion); String userCreateMessage = "User create redirect coverage"; - String userCreateRedirect = RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("status", "Open").multiPart("title", "User create redirect title") - .multiPart("message", userCreateMessage).multiPart("companyId", companyId) - .multiPart("companyEntitlementId", userCreateEntitlement.id) + String userCreateRedirect = RestAssured.given().redirects().follow(false).multiPart("status", "Open") + .multiPart("title", "User create redirect title").multiPart("message", userCreateMessage) + .multiPart("companyId", companyId).multiPart("companyEntitlementId", userCreateEntitlement.id) .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", userCreateVersion.id) .post("/user/tickets").then().statusCode(303).extract().header("Location"); Message createdUserMessage = Message.find("body", userCreateMessage).firstResult(); @@ -112,54 +115,48 @@ void userCanAccessUserTicketsMenu() { Long ticketId = userTicket == null ? null : userTicket.id; User supportUser = User.find("email", "support1@mnemosyne-systems.ai").firstResult(); User tamUser = User.find("email", "tam1@mnemosyne-systems.ai").firstResult(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/tickets/" + ticketId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/user/tickets/" + ticketId).then().statusCode(303) .header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets/" + ticketId).then() - .statusCode(200).body("name", Matchers.equalTo(userTicketName)) + RestAssured.given().get("/api/user/tickets/" + ticketId).then().statusCode(200) + .body("name", Matchers.equalTo(userTicketName)) .body("title", Matchers.equalTo(userTicket.displayTitle())) .body("supportUsers.username", Matchers.hasItem("support1")) .body("secondaryUsersLabel", Matchers.equalTo("TAM")).body("editableStatus", Matchers.equalTo(false)) .body("editableResolvedVersion", Matchers.equalTo(false)) .body("exportPath", Matchers.equalTo("/tickets/export/" + ticketId)); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/companies/" + companyId).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/user/companies/" + companyId).then().statusCode(303) .header("Location", Matchers.endsWith("/user/companies/" + companyId)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/companies/" + companyId).then() - .statusCode(200).body("id", Matchers.equalTo(companyId.intValue())) - .body("users.username", Matchers.hasItem("user")) + RestAssured.given().get("/api/user/companies/" + companyId).then().statusCode(200) + .body("id", Matchers.equalTo(companyId.intValue())).body("users.username", Matchers.hasItem("user")) .body("superusers.username", Matchers.hasItem("superuser1")).body("tamUsers", Matchers.empty()); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets/" + ticketId) - .then().statusCode(303).header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); + RestAssured.given().redirects().follow(false).get("/tickets/" + ticketId).then().statusCode(303) + .header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); User superuser = User.find("email", "superuser1@mnemosyne-systems.ai").firstResult(); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/superuser-users/" + superuser.id).then().statusCode(303) - .header("Location", Matchers.endsWith("/user/superuser-users/" + superuser.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/superuser-users/" + superuser.id) - .then().statusCode(200).body("typeLabel", Matchers.equalTo("Superuser")) - .body("username", Matchers.equalTo("superuser1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/support-users/" + supportUser.id).then().statusCode(303) - .header("Location", Matchers.endsWith("/user/support-users/" + supportUser.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/support-users/" + supportUser.id) - .then().statusCode(200).body("typeLabel", Matchers.equalTo("Support")) - .body("username", Matchers.equalTo("support1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/tam-users/" + tamUser.id).then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/user/superuser-users/" + superuser.id).then() + .statusCode(303).header("Location", Matchers.endsWith("/user/superuser-users/" + superuser.id)); + RestAssured.given().get("/api/user/superuser-users/" + superuser.id).then().statusCode(200) + .body("typeLabel", Matchers.equalTo("Superuser")).body("username", Matchers.equalTo("superuser1")); + RestAssured.given().redirects().follow(false).get("/user/support-users/" + supportUser.id).then() + .statusCode(303).header("Location", Matchers.endsWith("/user/support-users/" + supportUser.id)); + RestAssured.given().get("/api/user/support-users/" + supportUser.id).then().statusCode(200) + .body("typeLabel", Matchers.equalTo("Support")).body("username", Matchers.equalTo("support1")); + RestAssured.given().redirects().follow(false).get("/user/tam-users/" + tamUser.id).then().statusCode(303) .header("Location", Matchers.endsWith("/user/tam-users/" + tamUser.id)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tam-users/" + tamUser.id).then() - .statusCode(200).body("typeLabel", Matchers.equalTo("TAM")).body("username", Matchers.equalTo("tam1")); + RestAssured.given().get("/api/user/tam-users/" + tamUser.id).then().statusCode(200) + .body("typeLabel", Matchers.equalTo("TAM")).body("username", Matchers.equalTo("tam1")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .get("/user/tickets/" + ticketId + "/edit").then().statusCode(303) + RestAssured.given().redirects().follow(false).get("/user/tickets/" + ticketId + "/edit").then().statusCode(303) .header("Location", Matchers.endsWith("/user/tickets/" + ticketId)); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void userReplyUsesLatestPendingMessageForTicketSlaColor() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("User SLA Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); @@ -169,56 +166,59 @@ void userReplyUsesLatestPendingMessageForTicketSlaColor() { ensureTimedMessage(ticket, "Initial requester message", "user@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(10)); - String cookie = login("user", "user"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets").then().statusCode(200) + RestAssured.given().get("/api/user/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("Red")); - RestAssured.given().redirects().follow(false).cookie(AuthHelper.AUTH_COOKIE, cookie) - .multiPart("body", "Follow-up from requester").post("/user/tickets/" + ticket.id + "/messages").then() - .statusCode(303); + RestAssured.given().redirects().follow(false).multiPart("body", "Follow-up from requester") + .post("/user/tickets/" + ticket.id + "/messages").then().statusCode(303); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets").then().statusCode(200) + RestAssured.given().get("/api/user/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("White")); ensureTimedMessage(ticket, "Support follow-up", "support1@mnemosyne-systems.ai", java.time.LocalDateTime.now().minusMinutes(1)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/user/tickets").then().statusCode(200) + RestAssured.given().get("/api/user/tickets").then().statusCode(200) .body("items.find { it.id == " + ticket.id + " }.slaColor", Matchers.equalTo("White")); } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void userTicketSearchMatchesVisibleMessagesOnly() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); Long companyId = ensureCompany("User Search Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai"); Ticket visibleTicket = ensureTicket(companyId); String visibleBody = "user-visible-search-" + System.nanoTime(); ensureMessageWithBody(visibleTicket, visibleBody); - String cookie = login("user", "user"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", visibleBody).get("/api/user/tickets") - .then().statusCode(200).body("searchTerm", Matchers.equalTo(visibleBody)) + RestAssured.given().queryParam("q", visibleBody).get("/api/user/tickets").then().statusCode(200) + .body("searchTerm", Matchers.equalTo(visibleBody)) .body("items.name", Matchers.hasItem(visibleTicket.name)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).queryParam("q", "user-search-no-match") - .get("/api/user/tickets").then().statusCode(200).body("items.size()", Matchers.equalTo(0)); + RestAssured.given().queryParam("q", "user-search-no-match").get("/api/user/tickets").then().statusCode(200) + .body("items.size()", Matchers.equalTo(0)); } - @Test - void reactLoginRedirectsUsersToUserTickets() { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - - RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("username", "user") - .formParam("password", "user").post("/login").then().statusCode(303) - .header("Location", Matchers.equalTo("/user/tickets")); - } + // @Test + // void reactLoginRedirectsUsersToUserTickets() { + // ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + // + // RestAssured.given().redirects().follow(false).contentType(ContentType.URLENC).formParam("username", "user") + // .formParam("password", "user").post("/login").then().statusCode(303) + // .header("Location", Matchers.equalTo("/user/tickets")); + // } @Test + @TestSecurity(user = "user", roles = "user") + @JwtSecurity(claims = { @Claim(key = "email", value = "user@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user") }) void userTicketPdfExportIncludesAttachmentTitlesAndContent() throws Exception { - ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM, "tam1"); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); + ensureUser("tam1", "tam1@mnemosyne-systems.ai", User.TYPE_TAM); ensureDefaultCategories(); Long companyId = ensureCompany("PDF Export Co"); ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai", "tam1@mnemosyne-systems.ai"); @@ -229,9 +229,8 @@ void userTicketPdfExportIncludesAttachmentTitlesAndContent() throws Exception { ensureAttachment(message, "diagram.png", "image/png", smallPngBytes()); ensureAttachment(message, "embedded.pdf", "application/pdf", simplePdfBytes("Embedded PDF body")); - String cookie = login("user", "user"); - byte[] pdfBytes = RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/tickets/export/" + ticket.id) - .then().statusCode(200).contentType("application/pdf").extract().asByteArray(); + byte[] pdfBytes = RestAssured.given().get("/tickets/export/" + ticket.id).then().statusCode(200) + .contentType("application/pdf").extract().asByteArray(); try (PdfReader reader = new PdfReader(pdfBytes)) { PdfTextExtractor extractor = new PdfTextExtractor(reader); @@ -276,34 +275,38 @@ private byte[] smallPngBytes() throws IOException { } @Test + @TestSecurity(user = "support1", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "support1@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "support1") }) void reactArticlesApiReturnsArticleListAndDetail() { - ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT, "support1"); - String cookie = login("support1", "support1"); + ensureUser("support1", "support1@mnemosyne-systems.ai", User.TYPE_SUPPORT); ensureArticle("Zulu Runbook", "ops,prod", "## Zulu article\n\nOperational notes"); Article article = ensureArticle("Alpha Runbook", "ops,prod", "## Seed article\n\nOperational notes"); Attachment attachment = ensureArticleAttachment(article, "runbook.txt", "Attachment body"); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/articles").then().statusCode(200) - .body("canCreate", Matchers.equalTo(true)).body("createPath", Matchers.equalTo("/articles/new")) + RestAssured.given().get("/api/articles").then().statusCode(200).body("canCreate", Matchers.equalTo(true)) + .body("createPath", Matchers.equalTo("/articles/new")) .body("items.title", Matchers.hasItems("Alpha Runbook", "Zulu Runbook")); - List titles = RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/articles").then() - .statusCode(200).extract().jsonPath().getList("items.title"); + List titles = RestAssured.given().get("/api/articles").then().statusCode(200).extract().jsonPath() + .getList("items.title"); Assertions.assertTrue(titles.indexOf("Alpha Runbook") < titles.indexOf("Zulu Runbook")); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).get("/api/articles/" + article.id).then() - .statusCode(200).body("title", Matchers.equalTo("Alpha Runbook")) - .body("tags", Matchers.equalTo("ops,prod")).body("body", Matchers.containsString("Seed article")) - .body("canEdit", Matchers.equalTo(true)).body("canDelete", Matchers.equalTo(false)) + RestAssured.given().get("/api/articles/" + article.id).then().statusCode(200) + .body("title", Matchers.equalTo("Alpha Runbook")).body("tags", Matchers.equalTo("ops,prod")) + .body("body", Matchers.containsString("Seed article")).body("canEdit", Matchers.equalTo(true)) + .body("canDelete", Matchers.equalTo(false)) .body("editPath", Matchers.equalTo("/articles/" + article.id + "/edit")) .body("attachments.name", Matchers.hasItem("runbook.txt")) .body("attachments.downloadPath", Matchers.hasItem("/attachments/" + attachment.id + "/data")); } @Test + @TestSecurity(user = "user-json", roles = "support") + @JwtSecurity(claims = { @Claim(key = "email", value = "user-json@mnemosyne-systems.ai"), + @Claim(key = "sub", value = "user-json") }) void reactUserMutationsReturnJsonRedirects() { - ensureUser("user-json", "user-json@mnemosyne-systems.ai", User.TYPE_USER, "user-json"); + ensureUser("user-json", "user-json@mnemosyne-systems.ai", User.TYPE_USER); ensureDefaultCategories(); - String cookie = login("user-json", "user-json"); Long companyId = ensureCompany("User Json Co"); ensureCompanyUsers(companyId, "user-json@mnemosyne-systems.ai"); Entitlement entitlement = ensureEntitlement("User Json Entitlement", "User json detail"); @@ -312,8 +315,7 @@ void reactUserMutationsReturnJsonRedirects() { Assertions.assertNotNull(entry); Assertions.assertNotNull(version); - String redirectTo = RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie) - .header("X-Billetsys-Client", "react").multiPart("status", "Open") + String redirectTo = RestAssured.given().header("X-Billetsys-Client", "react").multiPart("status", "Open") .multiPart("title", "User json redirect title").multiPart("message", "User json redirect create") .multiPart("companyId", companyId).multiPart("companyEntitlementId", entry.id) .multiPart("categoryId", Category.findDefault().id).multiPart("affectsVersionId", version.id) @@ -321,10 +323,10 @@ void reactUserMutationsReturnJsonRedirects() { .body("redirectTo", Matchers.matchesPattern("/user/tickets/\\d+")).extract().path("redirectTo"); Long createdTicketId = Long.valueOf(redirectTo.substring(redirectTo.lastIndexOf('/') + 1)); - RestAssured.given().cookie(AuthHelper.AUTH_COOKIE, cookie).header("X-Billetsys-Client", "react") - .contentType(ContentType.URLENC).formParam("title", "User json redirect title") - .formParam("affectsVersionId", version.id).post("/user/tickets/" + createdTicketId).then() - .statusCode(200).body("redirectTo", Matchers.equalTo("/user/tickets/" + createdTicketId)); + RestAssured.given().header("X-Billetsys-Client", "react").contentType(ContentType.URLENC) + .formParam("title", "User json redirect title").formParam("affectsVersionId", version.id) + .post("/user/tickets/" + createdTicketId).then().statusCode(200) + .body("redirectTo", Matchers.equalTo("/user/tickets/" + createdTicketId)); } } diff --git a/src/backend/test/resources/application.properties b/src/backend/test/resources/application.properties index c54d82e2..4adc71e3 100644 --- a/src/backend/test/resources/application.properties +++ b/src/backend/test/resources/application.properties @@ -14,3 +14,19 @@ app.public-base-url=http://localhost:8081 cap.api.endpoint= cap.siteverify.url= cap.secret.key= + +quarkus.oidc.enabled=false +quarkus.oidc.tenant-enabled=false + +quarkus.test.security.enable=true + +quarkus.http.auth.permission.public.paths=/api/public/*,/api/app/session,/api/cap/*,/health/*,/metrics +quarkus.http.auth.permission.public.policy=permit +quarkus.http.auth.permission.public.priority=1 + +quarkus.http.auth.permission.authenticated.paths=/api/*,/articles/* +quarkus.http.auth.permission.authenticated.policy=authenticated +quarkus.http.auth.permission.authenticated.priority=2 + +quarkus.log.category."io.quarkus.oidc".level=ERROR +quarkus.log.category."io.quarkus.security".level=ERROR \ No newline at end of file diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index e78f8603..e11b6c78 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -39,6 +39,7 @@ "highlight.js": "^11.11.1", "iconsax-react": "^0.0.8", "iso-country-currency": "^0.7.2", + "keycloak-js": "^26.2.4", "lexical": "^0.43.0", "lodash": "^4.18.1", "lucide-react": "^1.8.0", @@ -3622,9 +3623,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3638,9 +3636,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3654,9 +3649,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3670,9 +3662,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3686,9 +3675,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3702,9 +3688,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3718,9 +3701,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3734,9 +3714,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3750,9 +3727,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3766,9 +3740,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3782,9 +3753,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3798,9 +3766,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3814,9 +3779,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4120,9 +4082,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4139,9 +4098,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4158,9 +4114,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4177,9 +4130,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8628,6 +8578,15 @@ "node": ">=4.0" } }, + "node_modules/keycloak-js": { + "version": "26.2.4", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", + "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8831,9 +8790,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8854,9 +8810,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8877,9 +8830,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8900,9 +8850,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/frontend/package.json b/src/frontend/package.json index ab78286e..8aa27655 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -54,6 +54,7 @@ "highlight.js": "^11.11.1", "iconsax-react": "^0.0.8", "iso-country-currency": "^0.7.2", + "keycloak-js": "^26.2.4", "lexical": "^0.43.0", "lodash": "^4.18.1", "lucide-react": "^1.8.0", diff --git a/src/frontend/src/auth/AuthContext.ts b/src/frontend/src/auth/AuthContext.ts new file mode 100644 index 00000000..a3c42b86 --- /dev/null +++ b/src/frontend/src/auth/AuthContext.ts @@ -0,0 +1,22 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { createContext } from "react"; +import keycloak from "./keycloak"; + +export interface AuthContextType { + keycloak: typeof keycloak; + authenticated: boolean; + initialized: boolean; +} + +export const AuthContext = createContext({ + keycloak, + authenticated: false, + initialized: false, +}); diff --git a/src/frontend/src/auth/AuthProvider.tsx b/src/frontend/src/auth/AuthProvider.tsx new file mode 100644 index 00000000..10825d67 --- /dev/null +++ b/src/frontend/src/auth/AuthProvider.tsx @@ -0,0 +1,147 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import React, { useEffect, useState } from "react"; +import { AuthContext } from "./AuthContext"; +import keycloak from "./keycloak"; + +let initPromise: Promise | null = null; + +const getInitPromise = () => { + if (!initPromise) { + initPromise = keycloak.init({ + onLoad: "check-sso", + checkLoginIframe: false, + }); + } + return initPromise; +}; + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [authenticated, setAuthenticated] = useState(false); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + let active = true; + let intervalId: ReturnType | undefined; + + const initKeycloak = async () => { + // Check parameters before Keycloak init clears them + const hash = window.location.hash || ""; + const search = window.location.search || ""; + if ( + (hash.includes("kc_action=UPDATE_PASSWORD") && + hash.includes("kc_action_status=success")) || + (search.includes("kc_action=UPDATE_PASSWORD") && + search.includes("kc_action_status=success")) + ) { + sessionStorage.setItem("password_update_success", "true"); + } + + try { + const isAuth = await getInitPromise(); + + if (!active) return; + + setAuthenticated(isAuth); + setInitialized(true); + + if (isAuth) { + // Token refresh loop every 15s + intervalId = setInterval(() => { + keycloak + .updateToken(30) + .then((refreshed) => { + if (refreshed) { + console.log("Token refreshed"); + } + }) + .catch(() => { + console.error("Failed to refresh token"); + keycloak.logout(); + }); + }, 15000); + } + } catch (err) { + console.error("Keycloak initialization failed:", err); + if (active) { + setInitialized(true); + } + } + }; + + initKeycloak(); + + return () => { + active = false; + if (intervalId) { + clearInterval(intervalId); + } + }; + }, []); + + // Globally patch fetch once Keycloak is configured + useEffect(() => { + const originalFetch = window.fetch; + window.fetch = async (input, init) => { + let requestInit = init || {}; + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : ""; + + // Only attach Authorization header to local API endpoints (/api/*) + const isLocalApi = + url.startsWith("/api/") || + url.includes(window.location.origin + "/api/"); + + if (isLocalApi && keycloak.token) { + try { + await keycloak.updateToken(30); + } catch (err) { + console.error("Token refresh failed during fetch intercept", err); + } + + const headers = new Headers(requestInit.headers); + headers.set("Authorization", `Bearer ${keycloak.token}`); + requestInit = { + ...requestInit, + headers, + }; + } + + return originalFetch(input, requestInit); + }; + + return () => { + window.fetch = originalFetch; + }; + }, []); + + if (!initialized) { + return ( +
+
+
+

+ Initializing secure session... +

+
+
+ ); + } + + return ( + + {children} + + ); +}; +export default AuthProvider; diff --git a/src/frontend/src/auth/keycloak.ts b/src/frontend/src/auth/keycloak.ts new file mode 100644 index 00000000..482514b3 --- /dev/null +++ b/src/frontend/src/auth/keycloak.ts @@ -0,0 +1,17 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import Keycloak from "keycloak-js"; + +const keycloak = new Keycloak({ + url: import.meta.env.VITE_KEYCLOAK_URL || "http://localhost:8180", + realm: import.meta.env.VITE_KEYCLOAK_REALM || "billetsys", + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || "billetsys-frontend", +}); + +export default keycloak; diff --git a/src/frontend/src/auth/useAuth.ts b/src/frontend/src/auth/useAuth.ts new file mode 100644 index 00000000..bb84c39f --- /dev/null +++ b/src/frontend/src/auth/useAuth.ts @@ -0,0 +1,14 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +import { useContext } from "react"; +import { AuthContext } from "./AuthContext"; + +export const useAuth = () => { + return useContext(AuthContext); +}; diff --git a/src/frontend/src/components/layout/AuthenticatedHeader.tsx b/src/frontend/src/components/layout/AuthenticatedHeader.tsx index 4da03c5d..b07d05be 100644 --- a/src/frontend/src/components/layout/AuthenticatedHeader.tsx +++ b/src/frontend/src/components/layout/AuthenticatedHeader.tsx @@ -15,6 +15,7 @@ import { installationCompanyName } from "../../utils/installationBranding"; import { formatInstallationClock, toQueryString } from "../../utils/formatting"; import { SmartLink, normalizeClientPath } from "../../utils/routing"; import { isEditableTarget } from "../../utils/keyboard"; +import { useAuth } from "../../auth/useAuth"; import { ticketCountsApiPath, ticketLabelForRole, @@ -60,6 +61,7 @@ export default function AuthenticatedHeader({ }: AuthenticatedHeaderProps) { const [now, setNow] = useState(() => new Date()); const [userMenuOpen, setUserMenuOpen] = useState(false); + const { keycloak } = useAuth(); const { darkModeToggleRef, toggleDarkMode } = useDarkModeToggle(); const location = useLocation(); const navigate = useNavigate(); @@ -519,9 +521,15 @@ export default function AuthenticatedHeader({ - setUserMenuOpen(false)}> + diff --git a/src/frontend/src/components/layout/SessionInactivityManager.tsx b/src/frontend/src/components/layout/SessionInactivityManager.tsx index 9d23ac3d..617c1fe7 100644 --- a/src/frontend/src/components/layout/SessionInactivityManager.tsx +++ b/src/frontend/src/components/layout/SessionInactivityManager.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Session } from "../../types/app"; +import { useAuth } from "../../auth/useAuth"; import { Button } from "../ui/button"; import { AlertDialog, @@ -35,6 +36,7 @@ function formatRemainingTime(totalSeconds: number): string { export default function SessionInactivityManager({ session, }: SessionInactivityManagerProps) { + const { keycloak } = useAuth(); const timeoutSeconds = session?.inactivityTimeoutSeconds || DEFAULT_TIMEOUT_SECONDS; const warningSeconds = @@ -54,15 +56,8 @@ export default function SessionInactivityManager({ } timeoutTriggeredRef.current = true; setWarningOpen(false); - try { - await fetch("/logout", { - credentials: "same-origin", - cache: "no-store", - }); - } finally { - window.location.replace("/login"); - } - }, []); + keycloak.logout({ redirectUri: window.location.origin }); + }, [keycloak]); const keepAlive = useCallback( async (force = false) => { diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 37cd300f..e48fb173 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -12,6 +12,8 @@ import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./app.css"; +import { AuthProvider } from "./auth/AuthProvider"; + const rootElement = document.getElementById("root"); if (!rootElement) { @@ -29,7 +31,9 @@ if ( ReactDOM.createRoot(rootElement).render( - + + + , ); diff --git a/src/frontend/src/pages/LoginPage.tsx b/src/frontend/src/pages/LoginPage.tsx index 3bcc0b4b..ebc66be4 100644 --- a/src/frontend/src/pages/LoginPage.tsx +++ b/src/frontend/src/pages/LoginPage.tsx @@ -6,7 +6,7 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Link, Navigate, useLocation } from "react-router-dom"; import type { SessionPageProps } from "../types/app"; import { @@ -19,13 +19,21 @@ import { Button } from "../components/ui/button"; import { Field, FieldLabel } from "../components/ui/field"; import { Input } from "../components/ui/input"; import { EyeIcon, EyeOffIcon } from "lucide-react"; +import { useAuth } from "../auth/useAuth"; export default function LoginPage({ sessionState }: SessionPageProps) { + const { keycloak, authenticated } = useAuth(); const session = sessionState.data; const location = useLocation(); const error = new URLSearchParams(location.search).get("error"); const [showPassword, setShowPassword] = useState(false); + useEffect(() => { + if (!authenticated) { + keycloak.login(); + } + }, [authenticated, keycloak]); + const branding = { ...readCachedInstallationBranding(), ...pickInstallationBranding(session), @@ -37,6 +45,28 @@ export default function LoginPage({ sessionState }: SessionPageProps) { return ; } + if (!authenticated) { + return ( +
+
+

+ Redirecting to authentication service... +

+
+ ); + } + + if (sessionState.loading) { + return ( +
+
+

+ Loading user session... +

+
+ ); + } + return (
diff --git a/src/frontend/src/pages/PasswordPage.tsx b/src/frontend/src/pages/PasswordPage.tsx index aa17d813..e1219774 100644 --- a/src/frontend/src/pages/PasswordPage.tsx +++ b/src/frontend/src/pages/PasswordPage.tsx @@ -6,131 +6,32 @@ * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. */ -import type { FormEvent } from "react"; -import { useEffect, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import DataState from "../components/common/DataState"; -import PageHeader from "../components/layout/PageHeader"; -import type { SessionPageProps } from "../types/app"; -import { Button } from "../components/ui/button"; -import { Field, FieldLabel } from "../components/ui/field"; -import { Input } from "../components/ui/input"; +import { useAuth } from "../auth/useAuth"; -export default function PasswordPage({ sessionState }: SessionPageProps) { - const location = useLocation(); - - const [formState, setFormState] = useState({ - oldPassword: "", - newPassword: "", - confirmPassword: "", - }); - const [saveState, setSaveState] = useState({ - saving: false, - error: "", - saved: false, - }); - const routeError = new URLSearchParams(location.search).get("error") || ""; +export default function PasswordPage() { + const { keycloak } = useAuth(); + const navigate = useNavigate(); useEffect(() => { - if (routeError) { - toast.error(routeError); + if (sessionStorage.getItem("password_update_success") === "true") { + sessionStorage.removeItem("password_update_success"); + toast.success("Password updated successfully!"); + navigate("/profile", { replace: true }); + return; } - }, [routeError]); - const submit = async (event: FormEvent) => { - event.preventDefault(); - setSaveState({ saving: true, error: "", saved: false }); - try { - const response = await fetch("/api/profile/password", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formState), - }); - if (!response.ok) { - throw new Error( - (await response.text()) || "Unable to update password.", - ); - } - setFormState({ oldPassword: "", newPassword: "", confirmPassword: "" }); - setSaveState({ saving: false, error: "", saved: true }); - toast.success("Password updated successfully."); - } catch (error: unknown) { - setSaveState({ - saving: false, - error: - error instanceof Error ? error.message : "Unable to update password.", - saved: false, - }); - } - }; + keycloak.login({ action: "UPDATE_PASSWORD" }); + }, [keycloak, navigate]); return ( -
- - -
- - Old password - - setFormState((current) => ({ - ...current, - oldPassword: event.target.value, - })) - } - required - /> - - - New password - - setFormState((current) => ({ - ...current, - newPassword: event.target.value, - })) - } - required - /> - - - Confirm password - - setFormState((current) => ({ - ...current, - confirmPassword: event.target.value, - })) - } - required - /> - - -
- -
-
-
-
+
+
+

+ Redirecting to secure password update service... +

+
); } diff --git a/src/frontend/src/routes/CoreRoutes.tsx b/src/frontend/src/routes/CoreRoutes.tsx index e2634a2f..f6ac33ae 100644 --- a/src/frontend/src/routes/CoreRoutes.tsx +++ b/src/frontend/src/routes/CoreRoutes.tsx @@ -1,4 +1,4 @@ -/* +/* * Eclipse Public License - v 2.0 * * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE @@ -57,7 +57,7 @@ export function getCoreRoutes(sessionState: SessionState): AppRoute[] { }, { path: PATHS.profilePassword, - element: , + element: , requiresAuth: true, }, {