From edd49f266f13730e6fe7795c7fc47bc95f50e756 Mon Sep 17 00:00:00 2001 From: Marc Parisi Date: Sun, 4 Jan 2026 09:26:05 -0500 Subject: [PATCH 1/6] initial commit --- .azure.env | 2 +- .../launcher/service/PodLauncherService.java | 3 + .../api/abac/AbacAgentController.java | 324 +++++++++++ .../api/abac/AccessPolicyController.java | 10 +- .../controllers/view/AbacViewController.java | 39 ++ .../abac/AbacAgentHealthCheckService.java | 118 ++++ .../resources/policies/abac-agent-policy.yaml | 93 +++ .../templates/sso/abac/agent_management.html | 261 +++++++++ .../templates/sso/attributes_unified.html | 34 +- .../templates/sso/users/user_settings.html | 2 + .../dto/agents/AgentExecutionContextDTO.java | 26 + .../services/agents/AgentClientService.java | 2 +- .../agents/ZeroTrustClientService.java | 38 ++ .../sso/core/config/SystemOptions.java | 5 + .../abac/AttributeManagementService.java | 109 +++- .../abac/AttributeManagementServiceTest.java | 130 +++++ .../abac/UserAttributeIntegrationTest.java | 315 ++++++++++ enterprise-agent/README-ABAC-AGENT.md | 396 +++++++++++++ .../analysis/agents/verbs/AbacVerbs.java | 541 ++++++++++++++++++ .../src/main/resources/abac-helper.yaml | 48 ++ sentrius-chart/templates/configmap.yaml | 1 + 21 files changed, 2489 insertions(+), 8 deletions(-) create mode 100644 api/src/main/java/io/sentrius/sso/controllers/api/abac/AbacAgentController.java create mode 100644 api/src/main/java/io/sentrius/sso/controllers/view/AbacViewController.java create mode 100644 api/src/main/java/io/sentrius/sso/services/abac/AbacAgentHealthCheckService.java create mode 100644 api/src/main/resources/policies/abac-agent-policy.yaml create mode 100644 api/src/main/resources/templates/sso/abac/agent_management.html create mode 100644 dataplane/src/test/java/io/sentrius/sso/core/services/abac/UserAttributeIntegrationTest.java create mode 100644 enterprise-agent/README-ABAC-AGENT.md create mode 100644 enterprise-agent/src/main/java/io/sentrius/agent/analysis/agents/verbs/AbacVerbs.java create mode 100644 enterprise-agent/src/main/resources/abac-helper.yaml diff --git a/.azure.env b/.azure.env index 6a3e5911..75913bed 100644 --- a/.azure.env +++ b/.azure.env @@ -1,4 +1,4 @@ -SENTRIUS_VERSION=1.1.113 +SENTRIUS_VERSION=1.1.120 SENTRIUS_SSH_VERSION=1.1.12 SENTRIUS_KEYCLOAK_VERSION=1.1.15 SENTRIUS_AGENT_VERSION=1.1.24 diff --git a/agent-launcher/src/main/java/io/sentrius/agent/launcher/service/PodLauncherService.java b/agent-launcher/src/main/java/io/sentrius/agent/launcher/service/PodLauncherService.java index 297d5977..97474f86 100644 --- a/agent-launcher/src/main/java/io/sentrius/agent/launcher/service/PodLauncherService.java +++ b/agent-launcher/src/main/java/io/sentrius/agent/launcher/service/PodLauncherService.java @@ -199,6 +199,9 @@ public V1Pod launchAgentPod(AgentRegistrationDTO agent) throws Exception { case "atpl-helper": agentFile = "chat-atpl-helper.yaml"; break; + case "abac": + agentFile = "abac-helper.yaml"; + break; case "default": default: agentFile = "chat-helper.yaml"; diff --git a/api/src/main/java/io/sentrius/sso/controllers/api/abac/AbacAgentController.java b/api/src/main/java/io/sentrius/sso/controllers/api/abac/AbacAgentController.java new file mode 100644 index 00000000..8822edc2 --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/api/abac/AbacAgentController.java @@ -0,0 +1,324 @@ +package io.sentrius.sso.controllers.api.abac; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.collect.Maps; +import io.sentrius.sso.config.ApiPaths; +import io.sentrius.sso.core.annotations.LimitAccess; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.dto.AgentRegistrationDTO; +import io.sentrius.sso.core.dto.UserDTO; +import io.sentrius.sso.core.dto.agents.AgentExecution; +import io.sentrius.sso.core.dto.ztat.TokenDTO; +import io.sentrius.sso.core.exceptions.ZtatException; +import io.sentrius.sso.core.model.security.enums.ApplicationAccessEnum; +import io.sentrius.sso.core.model.users.User; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import io.sentrius.sso.core.services.agents.AgentClientService; +import io.sentrius.sso.core.services.agents.ZeroTrustClientService; +import io.sentrius.sso.core.services.security.KeycloakService; +import io.sentrius.sso.core.utils.JsonUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Controller for ABAC agent management. + * Provides endpoints for launching, monitoring, and managing the ABAC agent. + */ +@Slf4j +@RestController +@RequestMapping(ApiPaths.API_V1 + "/abac/agent") +public class AbacAgentController extends BaseController { + + private final KeycloakService keycloakService; + private final AgentClientService agentClientService; + private final ZeroTrustClientService zeroTrustClientService; + + @Value("${sentrius.tenant:dev}") + private String agentNamespace; + + @Value("${sentrius.abac.agent.enabled:true}") + private boolean abacAgentEnabled; + + + private final SystemOptions systemOptions; + + public AbacAgentController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService, + KeycloakService keycloakService, + AgentClientService agentClientService, + ZeroTrustClientService zeroTrustClientService) { + super(userService, systemOptions, errorOutputService); + this.keycloakService = keycloakService; + this.agentClientService = agentClientService; + this.zeroTrustClientService = zeroTrustClientService; + this.systemOptions = systemOptions; + if (agentNamespace != null && !agentNamespace.isEmpty()) { + agentNamespace = agentNamespace + "-agents"; + } else { + agentNamespace = systemOptions.getAgentNamespace(); + } + } + + /** + * Launch the ABAC agent pod. + * Creates a new agent with the "abac" type and appropriate configuration. + * Uses ZeroTrustClientService for secure agent launcher communication. + */ + @PostMapping("/launch") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_MANAGE_APPLICATION}) + public ResponseEntity> launchAbacAgent( + HttpServletRequest request, + HttpServletResponse response) { + + if (!abacAgentEnabled) { + return ResponseEntity.ok(Map.of( + "status", "disabled", + "message", "ABAC agent is disabled in configuration. Set sentrius.abac.agent.enabled=true to enable." + )); + } + + try { + // Get the current user + User user = getOperatingUser(request, response); + if (user == null) { + log.warn("No operating user found for agent launch"); + return ResponseEntity.status(401).body(Map.of( + "status", "error", + "message", "No authenticated user found" + )); + } + + // Create AgentExecution for zero trust communication + UserDTO userDTO = UserDTO.builder() + .username(user.getUsername()) + .build(); + + AgentExecution execution = AgentExecution.builder() + .user(userDTO) + .ztatToken(keycloakService.getJwtToken()) + .build(); + + // Generate unique agent name + String agentName = "abac-evaluator-" + UUID.randomUUID().toString().substring(0, 8); + + // Create agent registration using builder + AgentRegistrationDTO agent = AgentRegistrationDTO.builder() + .agentName(agentName) + .agentType("abac") + .clientId("service-account-" + agentName) + .agentPolicyId("abac-agent-policy") + .build(); + + // Use AgentClientService to launch the agent via zero trust channel + String launcherResponse = agentClientService.createAgent(execution, agent); + + log.info("Agent launcher response: {}", launcherResponse); + + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("agentName", agentName); + result.put("agentType", "abac"); + result.put("namespace", agentNamespace); + result.put("message", "ABAC agent launched successfully"); + return ResponseEntity.ok(result); + + } catch (ZtatException e) { + log.error("Zero trust error launching ABAC agent", e); + return ResponseEntity.status(500).body(Map.of( + "status", "error", + "message", "Zero trust authentication failed: " + e.getMessage() + )); + } catch (JsonProcessingException e) { + log.error("JSON processing error launching ABAC agent", e); + return ResponseEntity.status(500).body(Map.of( + "status", "error", + "message", "Failed to process response: " + e.getMessage() + )); + } catch (Exception e) { + log.error("Error launching ABAC agent", e); + return ResponseEntity.status(500).body(Map.of( + "status", "error", + "message", "Failed to launch ABAC agent: " + e.getMessage() + )); + } + } + + /** + * Check the status of the ABAC agent. + * Queries the agent launcher to determine if the agent is running via ZeroTrustClientService. + */ + @GetMapping("/status") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_MANAGE_APPLICATION}) + public ResponseEntity> getAbacAgentStatus( + @RequestParam(name = "agentId", required = false) String agentId, + HttpServletRequest request, + HttpServletResponse response) { + + if (!abacAgentEnabled) { + return ResponseEntity.ok(Map.of( + "status", "disabled", + "enabled", false, + "message", "ABAC agent is disabled in configuration" + )); + } + + try { + // If no agentId provided, return the enabled status + if (agentId == null || agentId.isEmpty()) { + return ResponseEntity.ok(Map.of( + "status", "enabled", + "enabled", true, + "message", "ABAC agent is enabled" + )); + } + + // Get the current user for zero trust communication + User user = getOperatingUser(request, response); + if (user == null) { + log.warn("No operating user found for agent status check"); + return ResponseEntity.ok(Map.of( + "status", "error", + "enabled", abacAgentEnabled, + "message", "No authenticated user found" + )); + } + + // Create AgentExecution for zero trust communication + UserDTO userDTO = UserDTO.builder() + .username(user.getUsername()) + .build(); + + TokenDTO token = TokenDTO.builder().communicationId(UUID.randomUUID().toString()).build(); + // Query the agent launcher for status via zero trust channel + String statusResponse = agentClientService.getCreatedAgentStatus(token, agentId); + + if (statusResponse != null) { + @SuppressWarnings("unchecked") + Map result = JsonUtil.MAPPER.readValue(statusResponse, Map.class); + result.put("enabled", abacAgentEnabled); + return ResponseEntity.ok(result); + } else { + return ResponseEntity.ok(Map.of( + "status", "unknown", + "enabled", true, + "message", "Unable to retrieve agent status" + )); + } + + } catch (ZtatException e) { + log.error("Zero trust error checking ABAC agent status", e); + return ResponseEntity.ok(Map.of( + "status", "error", + "enabled", abacAgentEnabled, + "message", "Zero trust authentication failed: " + e.getMessage() + )); + } catch (JsonProcessingException e) { + log.error("JSON processing error checking ABAC agent status", e); + return ResponseEntity.ok(Map.of( + "status", "error", + "enabled", abacAgentEnabled, + "message", "Failed to process response: " + e.getMessage() + )); + } catch (Exception e) { + log.error("Error checking ABAC agent status", e); + return ResponseEntity.ok(Map.of( + "status", "error", + "enabled", abacAgentEnabled, + "message", "Error checking agent status: " + e.getMessage() + )); + } + } + + /** + * Shutdown the ABAC agent. + * Stops the running ABAC agent pod via ZeroTrustClientService. + */ + @DeleteMapping("/shutdown") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_MANAGE_APPLICATION}) + public ResponseEntity> shutdownAbacAgent( + @RequestParam(name = "agentId") String agentId, + HttpServletRequest request, + HttpServletResponse response) { + + try { + // Get the current user for zero trust communication + User user = getOperatingUser(request, response); + if (user == null) { + log.warn("No operating user found for agent shutdown"); + return ResponseEntity.status(401).body(Map.of( + "status", "error", + "message", "No authenticated user found" + )); + } + + // Use ZeroTrustClientService to shutdown the agent via zero trust channel + // Note: Using callAuthenticated*OnApi methods which handle authentication internally + zeroTrustClientService.callAuthenticatedGetOnApi( + "agent-launcher-service", // This should match your launcher service name + "agent/bootstrap/launcher/kill", + Maps.immutableEntry("agentId", List.of(agentId)) + ); + + log.info("ABAC agent {} shutdown successfully", agentId); + + return ResponseEntity.ok(Map.of( + "status", "success", + "message", "ABAC agent shutdown successfully" + )); + + } catch (ZtatException e) { + log.error("Zero trust error shutting down ABAC agent", e); + return ResponseEntity.status(500).body(Map.of( + "status", "error", + "message", "Zero trust authentication failed: " + e.getMessage() + )); + } catch (Exception e) { + log.error("Error shutting down ABAC agent", e); + return ResponseEntity.status(500).body(Map.of( + "status", "error", + "message", "Error shutting down ABAC agent: " + e.getMessage() + )); + } + } + + /** + * Get the current configuration for the ABAC agent. + */ + @GetMapping("/config") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_MANAGE_APPLICATION}) + public ResponseEntity> getAbacAgentConfig() { + Map config = new HashMap<>(); + config.put("enabled", abacAgentEnabled); + config.put("namespace", agentNamespace); + config.put("agentType", "abac"); + config.put("description", "ABAC agent for evaluating and managing user attribute access"); + return ResponseEntity.ok(config); + } + + /** + * Health check endpoint for the ABAC agent service. + * Can be called periodically from the API pod to ensure the service is available. + */ + @GetMapping("/health") + public ResponseEntity> healthCheck() { + Map health = new HashMap<>(); + health.put("status", "healthy"); + health.put("service", "abac-agent-controller"); + health.put("enabled", abacAgentEnabled); + health.put("timestamp", System.currentTimeMillis()); + return ResponseEntity.ok(health); + } +} diff --git a/api/src/main/java/io/sentrius/sso/controllers/api/abac/AccessPolicyController.java b/api/src/main/java/io/sentrius/sso/controllers/api/abac/AccessPolicyController.java index 07ea542a..785ea514 100644 --- a/api/src/main/java/io/sentrius/sso/controllers/api/abac/AccessPolicyController.java +++ b/api/src/main/java/io/sentrius/sso/controllers/api/abac/AccessPolicyController.java @@ -1,6 +1,8 @@ package io.sentrius.sso.controllers.api.abac; import io.sentrius.sso.core.annotations.LimitAccess; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; import io.sentrius.sso.core.dto.abac.AccessPolicyDTO; import io.sentrius.sso.core.dto.abac.PolicyRuleDTO; import io.sentrius.sso.core.model.abac.AccessPolicy; @@ -10,6 +12,8 @@ import io.sentrius.sso.core.repository.abac.AccessPolicyRepository; import io.sentrius.sso.core.repository.abac.AttributeDefinitionRepository; import io.sentrius.sso.core.repository.abac.PolicyRuleRepository; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; import io.sentrius.sso.core.services.abac.CustomAttributeMigrationService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -29,7 +33,7 @@ @Slf4j @RestController @RequestMapping("/api/v1/abac/policies") -public class AccessPolicyController { +public class AccessPolicyController extends BaseController { private static final boolean DEFAULT_IS_NEGATED = false; private static final int DEFAULT_EVALUATION_ORDER = 0; @@ -40,10 +44,14 @@ public class AccessPolicyController { private final CustomAttributeMigrationService migrationService; public AccessPolicyController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService, AccessPolicyRepository policyRepository, PolicyRuleRepository ruleRepository, AttributeDefinitionRepository attributeDefinitionRepository, CustomAttributeMigrationService migrationService) { + super(userService, systemOptions, errorOutputService); this.policyRepository = policyRepository; this.ruleRepository = ruleRepository; this.attributeDefinitionRepository = attributeDefinitionRepository; diff --git a/api/src/main/java/io/sentrius/sso/controllers/view/AbacViewController.java b/api/src/main/java/io/sentrius/sso/controllers/view/AbacViewController.java new file mode 100644 index 00000000..7b2d06e4 --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/view/AbacViewController.java @@ -0,0 +1,39 @@ +package io.sentrius.sso.controllers.view; + +import io.sentrius.sso.core.annotations.LimitAccess; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.model.security.enums.ApplicationAccessEnum; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * View controller for ABAC agent management UI. + */ +@Slf4j +@Controller +@RequestMapping("/sso/v1/abac") +public class AbacViewController extends BaseController { + + public AbacViewController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService) { + super(userService, systemOptions, errorOutputService); + } + + /** + * Display the ABAC agent management page. + */ + @GetMapping("/agent") + @LimitAccess(applicationAccess = {ApplicationAccessEnum.CAN_MANAGE_APPLICATION}) + public String abacAgentManagement(Model model) { + log.info("Accessing ABAC agent management page"); + return "sso/abac/agent_management"; + } +} diff --git a/api/src/main/java/io/sentrius/sso/services/abac/AbacAgentHealthCheckService.java b/api/src/main/java/io/sentrius/sso/services/abac/AbacAgentHealthCheckService.java new file mode 100644 index 00000000..30f777ac --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/services/abac/AbacAgentHealthCheckService.java @@ -0,0 +1,118 @@ +package io.sentrius.sso.services.abac; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service for monitoring ABAC agent health. + * Periodically checks if the ABAC agent is alive and records health status. + */ +@Slf4j +@Service +@ConditionalOnProperty(value = "sentrius.abac.agent.health-check.enabled", havingValue = "true", matchIfMissing = true) +public class AbacAgentHealthCheckService { + + private final RestTemplate restTemplate; + private final Map lastHealthCheck = new ConcurrentHashMap<>(); + private final Map healthStatus = new ConcurrentHashMap<>(); + + @Value("${sentrius.abac.agent.enabled:true}") + private boolean abacAgentEnabled; + + @Value("${sentrius.agent.launcher.url:http://localhost:8090}") + private String agentLauncherUrl; + + @Value("${sentrius.abac.agent.health-check.interval:300000}") // Default 5 minutes + private long healthCheckInterval; + + public AbacAgentHealthCheckService() { + this.restTemplate = new RestTemplate(); + } + + /** + * Periodically check the health of all ABAC agents. + * Runs every 5 minutes by default (configurable via sentrius.abac.agent.health-check.interval). + */ + @Scheduled(fixedDelayString = "${sentrius.abac.agent.health-check.interval:300000}") + public void checkAbacAgentHealth() { + if (!abacAgentEnabled) { + log.debug("ABAC agent health check skipped - agent is disabled"); + return; + } + + try { + log.debug("Performing ABAC agent health check"); + + // Check the agent launcher for ABAC agents + String healthUrl = agentLauncherUrl + "/api/v1/agent/launcher/status"; + + // For simplicity, we're checking the general health endpoint + // In production, this would query for specific ABAC agent instances + Map response = restTemplate.getForObject(healthUrl, Map.class); + + if (response != null) { + String status = response.getOrDefault("status", "unknown").toString(); + healthStatus.put("abac-agent-general", status); + lastHealthCheck.put("abac-agent-general", LocalDateTime.now()); + + if (!"running".equalsIgnoreCase(status) && !"Running".equals(status)) { + log.warn("ABAC agent health check indicates agent may not be running: {}", status); + } else { + log.debug("ABAC agent health check successful: {}", status); + } + } + + } catch (Exception e) { + log.error("Error during ABAC agent health check", e); + healthStatus.put("abac-agent-general", "error"); + lastHealthCheck.put("abac-agent-general", LocalDateTime.now()); + } + } + + /** + * Get the last health check time for an agent. + */ + public LocalDateTime getLastHealthCheck(String agentId) { + return lastHealthCheck.getOrDefault(agentId, null); + } + + /** + * Get the current health status for an agent. + */ + public String getHealthStatus(String agentId) { + return healthStatus.getOrDefault(agentId, "unknown"); + } + + /** + * Check if the agent is currently healthy (last check was successful and recent). + */ + public boolean isAgentHealthy(String agentId) { + LocalDateTime lastCheck = lastHealthCheck.get(agentId); + String status = healthStatus.get(agentId); + + if (lastCheck == null || status == null) { + return false; + } + + // Consider healthy if last check was within 2x the health check interval + long minutesSinceCheck = java.time.Duration.between(lastCheck, LocalDateTime.now()).toMinutes(); + long maxMinutes = (healthCheckInterval / 60000) * 2; // Convert ms to minutes and double it + + return "running".equalsIgnoreCase(status) && minutesSinceCheck < maxMinutes; + } + + /** + * Get all health statuses. + */ + public Map getAllHealthStatuses() { + return new ConcurrentHashMap<>(healthStatus); + } +} diff --git a/api/src/main/resources/policies/abac-agent-policy.yaml b/api/src/main/resources/policies/abac-agent-policy.yaml new file mode 100644 index 00000000..7911af47 --- /dev/null +++ b/api/src/main/resources/policies/abac-agent-policy.yaml @@ -0,0 +1,93 @@ +--- +version: "v0" +policy_id: "abac-agent-policy" +description: "Trust Policy for ABAC (Attribute-Based Access Control) Agent" +match: + agent_tags: + - "service:abac-evaluator" + - "type:attribute-management" +identity: + issuer: "https://keycloak.sentrius.internal" + subject_prefix: "service-account-abac-evaluator" + mfa_required: false + certificate_authority: "Sentrius-CA" +provenance: + source: "sentrius-abac-agent" + signature_required: false +runtime: + enclave_required: false + allow_drift: true +behavior: + minimum_positive_runs: 1 + max_incidents: 3 + incident_types: + denylist: + - "policy_violation" + - "unauthorized_access" +trust_score: + minimum: 60 + marginalThreshold: 40 + weightings: + identity: 0.3 + provenance: 0.2 + runtime: 0.2 + behavior: 0.3 +actions: + on_success: "allow" + on_failure: "deny" + on_marginal: + action: "require_ztat" + ztat_provider: "ztat-service.internal" +capabilities: + primitives: + - id: "abac_read" + description: "Read ABAC attribute definitions and assignments" + endpoints: + - "/api/v1/abac/user-attributes" + - "/api/v1/abac/user-attributes/user/*" + - "/api/v1/abac/attribute-definitions" + - "/api/v1/abac/attribute-definitions/scope/*" + - id: "abac_write" + description: "Create and modify ABAC attribute assignments" + endpoints: + - "/api/v1/abac/user-attributes" + - id: "abac_delete" + description: "Delete ABAC attribute assignments" + endpoints: + - "/api/v1/abac/user-attributes/*" + - id: "llm_access" + description: "Access LLM for attribute evaluation" + endpoints: + - "/api/v1/chat/completions" + - "/api/v1/llm/*" + - id: "agent_registration" + description: "Agent registration and bootstrap" + endpoints: + - "/api/v1/agent/bootstrap/register" + - "/api/v1/agent/register" + - id: "agent_context" + description: "Access agent context and memory" + endpoints: + - "/api/v1/agent/context/*" + - "/api/v1/agent/memory/*" + - id: "capabilities_discovery" + description: "Discover available capabilities and verbs" + endpoints: + - "/api/v1/capabilities/endpoints" + - "/api/v1/capabilities/verbs" + composed: + - id: "abac_full_management" + description: "Full ABAC attribute management capabilities" + requires: + - "abac_read" + - "abac_write" + - "abac_delete" + - "llm_access" +ztat: + provider: "ztat-service.internal" + ttl: "15m" + approved_issuers: + - "http://localhost:8080/" + - "https://sentrius.internal/" + key_binding: "RSA2048" + approval_required: false diff --git a/api/src/main/resources/templates/sso/abac/agent_management.html b/api/src/main/resources/templates/sso/abac/agent_management.html new file mode 100644 index 00000000..18d5ed56 --- /dev/null +++ b/api/src/main/resources/templates/sso/abac/agent_management.html @@ -0,0 +1,261 @@ + + + + + [[${systemOptions.systemLogoName}]] - ABAC Agent Management + + + + + +
+
+ +
+
+
+ +

ABAC Agent Management

+ +
+
+
Agent Status
+
+
+
+
+

Service: ABAC Attribute Evaluator

+

Type: abac

+

Description: Evaluates user attribute access requests using LLM-powered justification analysis

+
+
+

Status: Checking...

+

Agent ID: N/A

+

Namespace: Loading...

+
+
+
+ + + +
+
+
+ +
+
+
Features
+
+
+
    +
  • Intelligent Evaluation: Uses LLM to assess attribute access requests based on justification
  • +
  • Time-Based Expiration: Automatically revokes attributes after specified expiry time
  • +
  • Memory Management: Maintains history of evaluations, assignments, and expiry times
  • +
  • Trust Policy: Operates under defined trust policy with appropriate capabilities
  • +
+
+
+ +
+
+
Available Verbs
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VerbDescription
evaluate_attribute_accessEvaluates if user should have access to an attribute based on justification
assign_user_attributeAssigns attribute to user with optional expiry time
revoke_user_attributeRevokes attribute from user
list_user_attributesLists all active attributes for a user
check_expired_attributesChecks and revokes expired attributes
+
+
+ +
+
+
Configuration
+
+
+
Loading configuration...
+
+
+ +
+
+
+
+ + + + + diff --git a/api/src/main/resources/templates/sso/attributes_unified.html b/api/src/main/resources/templates/sso/attributes_unified.html index 26f3bef9..4a0ee232 100644 --- a/api/src/main/resources/templates/sso/attributes_unified.html +++ b/api/src/main/resources/templates/sso/attributes_unified.html @@ -521,10 +521,42 @@
- + + +
+ diff --git a/api/src/main/resources/templates/sso/users/user_settings.html b/api/src/main/resources/templates/sso/users/user_settings.html index d2264d8d..cbbe578b 100755 --- a/api/src/main/resources/templates/sso/users/user_settings.html +++ b/api/src/main/resources/templates/sso/users/user_settings.html @@ -181,6 +181,8 @@

User Preferences

+
+
diff --git a/core/src/main/java/io/sentrius/sso/core/dto/agents/AgentExecutionContextDTO.java b/core/src/main/java/io/sentrius/sso/core/dto/agents/AgentExecutionContextDTO.java index a2299714..30f2f989 100644 --- a/core/src/main/java/io/sentrius/sso/core/dto/agents/AgentExecutionContextDTO.java +++ b/core/src/main/java/io/sentrius/sso/core/dto/agents/AgentExecutionContextDTO.java @@ -55,6 +55,32 @@ public void addToMemory(String key, JsonNode value) { putStructuredToMemory(key, value); } + public void addToMemory(String key, String value) { + log.info("Adding to memory key: {} with string value", key); + JsonNode jsonValue = JsonUtil.MAPPER.convertValue(value, JsonNode.class); + putStructuredToMemory(key, jsonValue); + } + + public Optional getFromMemory(String key) { + log.info("Getting from memory key: {}", key); + JsonNode value = agentShortTermMemory.get(key); + if (value != null) { + if (value.isTextual()) { + return Optional.of(value.asText()); + } else { + return Optional.of(value.toString()); + } + } + return Optional.empty(); + } + + public void removeFromMemory(String key) { + log.info("Removing from memory key: {}", key); + agentShortTermMemory.remove(key); + // Also remove from long-term memories if present + longTermMemories.remove(key); + } + public void putStructuredToMemory(String key, JsonNode value) { agentShortTermMemory.put(key, value); // Optional: Add to agentDataList if you want to preserve all data too diff --git a/core/src/main/java/io/sentrius/sso/core/services/agents/AgentClientService.java b/core/src/main/java/io/sentrius/sso/core/services/agents/AgentClientService.java index 803efc41..1abcb798 100644 --- a/core/src/main/java/io/sentrius/sso/core/services/agents/AgentClientService.java +++ b/core/src/main/java/io/sentrius/sso/core/services/agents/AgentClientService.java @@ -329,7 +329,7 @@ public String createAgent(AgentExecution execution, AgentRegistrationDTO registr return acommResponse; } - public String getCreatedAgentStatus(AgentExecution execution, String agentId) + public String getCreatedAgentStatus(TokenDTO execution, String agentId) throws ZtatException, JsonProcessingException { String ask = "/agent/bootstrap/launcher/status"; diff --git a/core/src/main/java/io/sentrius/sso/core/services/agents/ZeroTrustClientService.java b/core/src/main/java/io/sentrius/sso/core/services/agents/ZeroTrustClientService.java index 0dc1c59c..0f3bdbd6 100644 --- a/core/src/main/java/io/sentrius/sso/core/services/agents/ZeroTrustClientService.java +++ b/core/src/main/java/io/sentrius/sso/core/services/agents/ZeroTrustClientService.java @@ -881,4 +881,42 @@ public PagedResultDTO callPostOnApi( } } + /** + * Makes a DELETE request to the API with the given endpoint. + * + * @param token The authentication token + * @param apiEndpoint The API endpoint path + * @return Response body as string + * @throws ZtatException If ZTAT token validation fails + */ + public String callDeleteOnApi(@NonNull TokenDTO token, @NonNull String apiEndpoint) throws ZtatException { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(getKeycloakToken()); + headers.set("X-Ztat-Token", token.getZtatToken()); + + HttpEntity requestEntity = new HttpEntity<>(headers); + + String url = agentApiUrl + apiEndpoint; + log.info("DELETE request to: {}", url); + + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, requestEntity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + return response.getBody() != null ? response.getBody() : ""; + } else { + throw new RuntimeException("DELETE request failed: " + response.getStatusCode()); + } + + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.PRECONDITION_REQUIRED) { + throw new ZtatException(e.getResponseBodyAsString(), apiEndpoint); + } else { + log.error("DELETE request error: {}", e.getResponseBodyAsString()); + throw new RuntimeException("DELETE request failed: " + e.getResponseBodyAsString()); + } + } + } + } diff --git a/dataplane/src/main/java/io/sentrius/sso/core/config/SystemOptions.java b/dataplane/src/main/java/io/sentrius/sso/core/config/SystemOptions.java index 7bee6eb0..663dd722 100644 --- a/dataplane/src/main/java/io/sentrius/sso/core/config/SystemOptions.java +++ b/dataplane/src/main/java/io/sentrius/sso/core/config/SystemOptions.java @@ -149,6 +149,8 @@ public class SystemOptions { @Updatable(description = "Default LLM provider for automation and AI services (openai, claude, etc.)") @Builder.Default public String defaultLlmProvider = "openai"; + + public Boolean lockdownEnabled = false; @Updatable(description = "AI risk score before user sessions are halted. Changes won't apply to currently running " + @@ -182,6 +184,9 @@ public class SystemOptions { @Builder.Default public String integrationProxyUrl = "http://sentrius-integrationproxy:8080/"; + @Updatable(description = "Agent namespace name.") + @Builder.Default + public String agentNamespace = "default"; // the default path may be sufficient diff --git a/dataplane/src/main/java/io/sentrius/sso/core/services/abac/AttributeManagementService.java b/dataplane/src/main/java/io/sentrius/sso/core/services/abac/AttributeManagementService.java index 74ff90f3..bca2c6e8 100644 --- a/dataplane/src/main/java/io/sentrius/sso/core/services/abac/AttributeManagementService.java +++ b/dataplane/src/main/java/io/sentrius/sso/core/services/abac/AttributeManagementService.java @@ -2,8 +2,10 @@ import io.sentrius.sso.core.model.abac.AttributeAssignment; import io.sentrius.sso.core.model.abac.AttributeDefinition; +import io.sentrius.sso.core.model.users.UserAttribute; import io.sentrius.sso.core.repository.abac.AttributeAssignmentRepository; import io.sentrius.sso.core.repository.abac.AttributeDefinitionRepository; +import io.sentrius.sso.core.repository.UserAttributeRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.stereotype.Service; @@ -28,14 +30,17 @@ public class AttributeManagementService { private final AttributeDefinitionRepository definitionRepository; private final AttributeAssignmentRepository assignmentRepository; + private final UserAttributeRepository userAttributeRepository; private final io.sentrius.sso.core.services.security.KeycloakService keycloakService; public AttributeManagementService( AttributeDefinitionRepository definitionRepository, AttributeAssignmentRepository assignmentRepository, + UserAttributeRepository userAttributeRepository, io.sentrius.sso.core.services.security.KeycloakService keycloakService) { this.definitionRepository = definitionRepository; this.assignmentRepository = assignmentRepository; + this.userAttributeRepository = userAttributeRepository; this.keycloakService = keycloakService; } @@ -131,18 +136,19 @@ public AttributeAssignment assignAttribute( .findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( definition, targetType, targetId); + AttributeAssignment assignment; if (existing.isPresent()) { // Update existing assignment - AttributeAssignment assignment = existing.get(); + assignment = existing.get(); assignment.setAttributeValue(value); assignment.setSource(source); assignment.setSyncedFromKeycloak(syncedFromKeycloak); log.debug("Updated attribute assignment: {} for {}:{}", definition.getAttributeName(), targetType, targetId); - return assignmentRepository.save(assignment); + assignment = assignmentRepository.save(assignment); } else { // Create new assignment - AttributeAssignment assignment = AttributeAssignment.builder() + assignment = AttributeAssignment.builder() .attributeDefinition(definition) .targetType(targetType) .targetId(targetId) @@ -154,7 +160,68 @@ public AttributeAssignment assignAttribute( .build(); log.info("Created new attribute assignment: {} = {} for {}:{} from {}", definition.getAttributeName(), value, targetType, targetId, source); - return assignmentRepository.save(assignment); + assignment = assignmentRepository.save(assignment); + } + + // IMPORTANT: Also persist to UserAttribute table for USER target types + // This ensures compatibility with DocumentAccessControlService and MemoryAccessControlService + if (targetType == AttributeAssignment.TargetType.USER) { + syncToUserAttributeTable(targetId, definition.getAttributeName(), value, + source.name(), syncedFromKeycloak); + } + + return assignment; + } + + /** + * Sync an attribute to the UserAttribute table for backward compatibility + * This ensures attributes work with DocumentAccessControlService and other services + * that query the user_attributes table + */ + private void syncToUserAttributeTable(String userId, String attributeName, String attributeValue, + String source, boolean syncedFromKeycloak) { + try { + // Check if UserAttribute already exists + Optional existingUserAttr = userAttributeRepository + .findByUserIdAndAttributeNameAndIsActiveTrue(userId, attributeName); + + if (existingUserAttr.isPresent()) { + // Update existing UserAttribute + UserAttribute userAttr = existingUserAttr.get(); + userAttr.setAttributeValue(attributeValue); + userAttr.setSource(source); + userAttr.setSyncedFromKeycloak(syncedFromKeycloak); + userAttributeRepository.save(userAttr); + log.debug("Updated UserAttribute for user: {}, attribute: {}", userId, attributeName); + } else { + // Create new UserAttribute - use STRING as default type for simplicity + // UserAttribute has basic type checking (STRING, INTEGER, BOOLEAN, etc.) + // while AttributeDefinition has more complex type system + UserAttribute userAttr = UserAttribute.builder() + .userId(userId) + .attributeName(attributeName) + .attributeValue(attributeValue) + .attributeType("STRING") + .source(source) + .isActive(true) + .syncedFromKeycloak(syncedFromKeycloak) + .build(); + userAttributeRepository.save(userAttr); + log.info("Created UserAttribute for user: {}, attribute: {} = {}", + userId, attributeName, attributeValue); + } + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.error("Database constraint violation syncing attribute to UserAttribute table for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation if UserAttribute sync fails + } catch (org.springframework.dao.DataAccessException e) { + log.error("Database error syncing attribute to UserAttribute table for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation if UserAttribute sync fails + } catch (Exception e) { + log.error("Unexpected error syncing attribute to UserAttribute table for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation if UserAttribute sync fails } } @@ -305,10 +372,44 @@ public boolean removeAttributeAssignment(Long assignmentId) { a.setIsActive(false); assignmentRepository.save(a); log.info("Deactivated attribute assignment: {}", assignmentId); + + // Also deactivate corresponding UserAttribute if this is a USER assignment + if (a.getTargetType() == AttributeAssignment.TargetType.USER) { + deactivateUserAttribute(a.getTargetId(), a.getAttributeDefinition().getAttributeName()); + } + return true; } return false; } + + /** + * Deactivate a UserAttribute for backward compatibility + */ + private void deactivateUserAttribute(String userId, String attributeName) { + try { + Optional userAttr = userAttributeRepository + .findByUserIdAndAttributeNameAndIsActiveTrue(userId, attributeName); + if (userAttr.isPresent()) { + UserAttribute attr = userAttr.get(); + attr.setIsActive(false); + userAttributeRepository.save(attr); + log.debug("Deactivated UserAttribute for user: {}, attribute: {}", userId, attributeName); + } + } catch (org.springframework.dao.DataIntegrityViolationException e) { + log.error("Database constraint violation deactivating UserAttribute for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation + } catch (org.springframework.dao.DataAccessException e) { + log.error("Database error deactivating UserAttribute for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation + } catch (Exception e) { + log.error("Unexpected error deactivating UserAttribute for user: {}, attribute: {}", + userId, attributeName, e); + // Don't fail the main operation + } + } /** * Get all attribute definitions diff --git a/dataplane/src/test/java/io/sentrius/sso/core/services/abac/AttributeManagementServiceTest.java b/dataplane/src/test/java/io/sentrius/sso/core/services/abac/AttributeManagementServiceTest.java index 1618f944..7389aff7 100644 --- a/dataplane/src/test/java/io/sentrius/sso/core/services/abac/AttributeManagementServiceTest.java +++ b/dataplane/src/test/java/io/sentrius/sso/core/services/abac/AttributeManagementServiceTest.java @@ -25,6 +25,9 @@ class AttributeManagementServiceTest { @Mock private AttributeAssignmentRepository assignmentRepository; + @Mock + private io.sentrius.sso.core.repository.UserAttributeRepository userAttributeRepository; + @Mock private io.sentrius.sso.core.services.security.KeycloakService keycloakService; @@ -35,6 +38,7 @@ void setUp() { attributeManagementService = new AttributeManagementService( definitionRepository, assignmentRepository, + userAttributeRepository, keycloakService ); } @@ -204,6 +208,129 @@ void testRemoveAttributeAssignment_DeactivatesAssignment() { .thenReturn(Optional.of(assignment)); when(assignmentRepository.save(any(AttributeAssignment.class))) .thenReturn(assignment); + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue(anyString(), anyString())) + .thenReturn(Optional.empty()); + + // Act + boolean result = attributeManagementService.removeAttributeAssignment(1L); + + // Assert + assertTrue(result); + assertFalse(assignment.getIsActive()); + verify(assignmentRepository).save(assignment); + } + + @Test + void testAssignAttribute_UserTargetType_AlsoCreatesUserAttribute() { + // Arrange + AttributeDefinition definition = createAttributeDefinition(); + + when(assignmentRepository.findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( + any(), any(), anyString())) + .thenReturn(Optional.empty()); + + AttributeAssignment savedAssignment = createAttributeAssignment(definition); + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(savedAssignment); + + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue(anyString(), anyString())) + .thenReturn(Optional.empty()); + + io.sentrius.sso.core.model.users.UserAttribute savedUserAttr = + io.sentrius.sso.core.model.users.UserAttribute.builder() + .id(1L) + .userId("user123") + .attributeName("department") + .attributeValue("engineering") + .attributeType("STRING") + .source("SENTRIUS") + .isActive(true) + .syncedFromKeycloak(false) + .build(); + + when(userAttributeRepository.save(any(io.sentrius.sso.core.model.users.UserAttribute.class))) + .thenReturn(savedUserAttr); + + // Act + AttributeAssignment result = attributeManagementService.assignAttribute( + definition, + AttributeAssignment.TargetType.USER, + "user123", + "engineering", + AttributeAssignment.AssignmentSource.SENTRIUS, + false + ); + + // Assert + assertNotNull(result); + verify(assignmentRepository).save(any(AttributeAssignment.class)); + // Verify that UserAttribute was also saved + verify(userAttributeRepository).save(any(io.sentrius.sso.core.model.users.UserAttribute.class)); + } + + @Test + void testAssignAttribute_NonUserTargetType_DoesNotCreateUserAttribute() { + // Arrange + AttributeDefinition definition = createAttributeDefinition(); + + when(assignmentRepository.findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( + any(), any(), anyString())) + .thenReturn(Optional.empty()); + + AttributeAssignment savedAssignment = AttributeAssignment.builder() + .id(1L) + .attributeDefinition(definition) + .targetType(AttributeAssignment.TargetType.ENDPOINT) + .targetId("/api/data") + .attributeValue("high") + .source(AttributeAssignment.AssignmentSource.SENTRIUS) + .isActive(true) + .build(); + + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(savedAssignment); + + // Act + AttributeAssignment result = attributeManagementService.assignAttribute( + definition, + AttributeAssignment.TargetType.ENDPOINT, + "/api/data", + "high", + AttributeAssignment.AssignmentSource.SENTRIUS, + false + ); + + // Assert + assertNotNull(result); + verify(assignmentRepository).save(any(AttributeAssignment.class)); + // Verify that UserAttribute was NOT saved for non-USER target types + verify(userAttributeRepository, never()).save(any()); + } + + @Test + void testRemoveAttributeAssignment_UserTargetType_AlsoDeactivatesUserAttribute() { + // Arrange + AttributeAssignment assignment = createAttributeAssignment(createAttributeDefinition()); + assignment.setIsActive(true); + + when(assignmentRepository.findById(1L)) + .thenReturn(Optional.of(assignment)); + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(assignment); + + io.sentrius.sso.core.model.users.UserAttribute existingUserAttr = + io.sentrius.sso.core.model.users.UserAttribute.builder() + .id(1L) + .userId("user123") + .attributeName("department") + .attributeValue("engineering") + .isActive(true) + .build(); + + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue("user123", "department")) + .thenReturn(Optional.of(existingUserAttr)); + when(userAttributeRepository.save(any(io.sentrius.sso.core.model.users.UserAttribute.class))) + .thenReturn(existingUserAttr); // Act boolean result = attributeManagementService.removeAttributeAssignment(1L); @@ -212,6 +339,9 @@ void testRemoveAttributeAssignment_DeactivatesAssignment() { assertTrue(result); assertFalse(assignment.getIsActive()); verify(assignmentRepository).save(assignment); + // Verify that UserAttribute was also deactivated + verify(userAttributeRepository).save(any(io.sentrius.sso.core.model.users.UserAttribute.class)); + assertFalse(existingUserAttr.getIsActive()); } // Helper methods diff --git a/dataplane/src/test/java/io/sentrius/sso/core/services/abac/UserAttributeIntegrationTest.java b/dataplane/src/test/java/io/sentrius/sso/core/services/abac/UserAttributeIntegrationTest.java new file mode 100644 index 00000000..ae6b4980 --- /dev/null +++ b/dataplane/src/test/java/io/sentrius/sso/core/services/abac/UserAttributeIntegrationTest.java @@ -0,0 +1,315 @@ +package io.sentrius.sso.core.services.abac; + +import io.sentrius.sso.core.model.abac.AttributeAssignment; +import io.sentrius.sso.core.model.abac.AttributeDefinition; +import io.sentrius.sso.core.model.users.UserAttribute; +import io.sentrius.sso.core.repository.UserAttributeRepository; +import io.sentrius.sso.core.repository.abac.AttributeAssignmentRepository; +import io.sentrius.sso.core.repository.abac.AttributeDefinitionRepository; +import io.sentrius.sso.core.services.security.KeycloakService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Integration test to verify that user attributes persist correctly + * to both AttributeAssignment and UserAttribute tables for access control. + */ +@ExtendWith(MockitoExtension.class) +class UserAttributeIntegrationTest { + + @Mock + private AttributeDefinitionRepository definitionRepository; + + @Mock + private AttributeAssignmentRepository assignmentRepository; + + @Mock + private UserAttributeRepository userAttributeRepository; + + @Mock + private KeycloakService keycloakService; + + private AttributeManagementService attributeManagementService; + + @BeforeEach + void setUp() { + attributeManagementService = new AttributeManagementService( + definitionRepository, + assignmentRepository, + userAttributeRepository, + keycloakService + ); + } + + /** + * Test that user attributes created through ABAC UI are accessible + * for document visibility expressions and access control checks. + * + * This verifies the fix for the issue where attributes were stored in + * AttributeAssignment table but not in UserAttribute table, causing + * document access control to fail. + */ + @Test + void testUserAttributeCreation_PersistsToBothTables() { + // Arrange - Setup attribute definition + AttributeDefinition definition = AttributeDefinition.builder() + .id(1L) + .attributeName("clearance_level") + .attributeScope(AttributeDefinition.AttributeScope.SUBJECT) + .attributeType(AttributeDefinition.AttributeType.STRING) + .isActive(true) + .build(); + + lenient().when(definitionRepository.findByAttributeNameAndAttributeScope( + "clearance_level", AttributeDefinition.AttributeScope.SUBJECT)) + .thenReturn(Optional.of(definition)); + + // Setup AttributeAssignment repository + when(assignmentRepository.findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( + any(), any(), anyString())) + .thenReturn(Optional.empty()); + + AttributeAssignment savedAssignment = AttributeAssignment.builder() + .id(1L) + .attributeDefinition(definition) + .targetType(AttributeAssignment.TargetType.USER) + .targetId("john@example.com") + .attributeValue("high") + .source(AttributeAssignment.AssignmentSource.SENTRIUS) + .isActive(true) + .build(); + + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(savedAssignment); + + // Setup UserAttribute repository + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue( + "john@example.com", "clearance_level")) + .thenReturn(Optional.empty()); + + UserAttribute savedUserAttribute = UserAttribute.builder() + .id(1L) + .userId("john@example.com") + .attributeName("clearance_level") + .attributeValue("high") + .attributeType("STRING") + .source("SENTRIUS") + .isActive(true) + .syncedFromKeycloak(false) + .build(); + + when(userAttributeRepository.save(any(UserAttribute.class))) + .thenReturn(savedUserAttribute); + + // Act - Assign attribute through ABAC service (as UI does) + AttributeAssignment result = attributeManagementService.assignAttribute( + definition, + AttributeAssignment.TargetType.USER, + "john@example.com", + "high", + AttributeAssignment.AssignmentSource.SENTRIUS, + false + ); + + // Assert - Verify attribute was saved to both tables + assertNotNull(result); + assertEquals("john@example.com", result.getTargetId()); + assertEquals("high", result.getAttributeValue()); + + // Verify AttributeAssignment was saved + verify(assignmentRepository).save(any(AttributeAssignment.class)); + + // CRITICAL: Verify UserAttribute was also saved for access control + verify(userAttributeRepository).save(argThat(attr -> + attr.getUserId().equals("john@example.com") && + attr.getAttributeName().equals("clearance_level") && + attr.getAttributeValue().equals("high") && + attr.getIsActive() && + !attr.getSyncedFromKeycloak() + )); + } + + /** + * Test that updating a user attribute updates both tables. + */ + @Test + void testUserAttributeUpdate_UpdatesBothTables() { + // Arrange + AttributeDefinition definition = AttributeDefinition.builder() + .id(1L) + .attributeName("department") + .attributeScope(AttributeDefinition.AttributeScope.SUBJECT) + .attributeType(AttributeDefinition.AttributeType.STRING) + .isActive(true) + .build(); + + // Existing AttributeAssignment + AttributeAssignment existingAssignment = AttributeAssignment.builder() + .id(1L) + .attributeDefinition(definition) + .targetType(AttributeAssignment.TargetType.USER) + .targetId("jane@example.com") + .attributeValue("engineering") + .source(AttributeAssignment.AssignmentSource.SENTRIUS) + .isActive(true) + .build(); + + when(assignmentRepository.findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( + any(), any(), anyString())) + .thenReturn(Optional.of(existingAssignment)); + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(existingAssignment); + + // Existing UserAttribute + UserAttribute existingUserAttr = UserAttribute.builder() + .id(1L) + .userId("jane@example.com") + .attributeName("department") + .attributeValue("engineering") + .attributeType("STRING") + .isActive(true) + .build(); + + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue( + "jane@example.com", "department")) + .thenReturn(Optional.of(existingUserAttr)); + when(userAttributeRepository.save(any(UserAttribute.class))) + .thenReturn(existingUserAttr); + + // Act - Update attribute value + AttributeAssignment result = attributeManagementService.assignAttribute( + definition, + AttributeAssignment.TargetType.USER, + "jane@example.com", + "sales", // Changed from "engineering" to "sales" + AttributeAssignment.AssignmentSource.SENTRIUS, + false + ); + + // Assert + assertNotNull(result); + assertEquals("sales", result.getAttributeValue()); + + // Verify both tables were updated + verify(assignmentRepository).save(any(AttributeAssignment.class)); + verify(userAttributeRepository).save(argThat(attr -> + attr.getAttributeValue().equals("sales") + )); + } + + /** + * Test that deleting a user attribute deactivates it in both tables. + */ + @Test + void testUserAttributeDelete_DeactivatesBothTables() { + // Arrange + AttributeDefinition definition = AttributeDefinition.builder() + .id(1L) + .attributeName("temp_access") + .attributeScope(AttributeDefinition.AttributeScope.SUBJECT) + .attributeType(AttributeDefinition.AttributeType.STRING) + .isActive(true) + .build(); + + AttributeAssignment assignment = AttributeAssignment.builder() + .id(1L) + .attributeDefinition(definition) + .targetType(AttributeAssignment.TargetType.USER) + .targetId("temp@example.com") + .attributeValue("granted") + .isActive(true) + .build(); + + UserAttribute userAttr = UserAttribute.builder() + .id(1L) + .userId("temp@example.com") + .attributeName("temp_access") + .attributeValue("granted") + .isActive(true) + .build(); + + when(assignmentRepository.findById(1L)) + .thenReturn(Optional.of(assignment)); + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(assignment); + when(userAttributeRepository.findByUserIdAndAttributeNameAndIsActiveTrue( + "temp@example.com", "temp_access")) + .thenReturn(Optional.of(userAttr)); + when(userAttributeRepository.save(any(UserAttribute.class))) + .thenReturn(userAttr); + + // Act - Delete attribute + boolean result = attributeManagementService.removeAttributeAssignment(1L); + + // Assert + assertTrue(result); + assertFalse(assignment.getIsActive()); + assertFalse(userAttr.getIsActive()); + + // Verify both tables were updated to deactivate + verify(assignmentRepository).save(assignment); + verify(userAttributeRepository).save(userAttr); + } + + /** + * Test that non-USER target types do not create UserAttribute entries. + */ + @Test + void testEndpointAttribute_OnlyCreatesAttributeAssignment() { + // Arrange + AttributeDefinition definition = AttributeDefinition.builder() + .id(1L) + .attributeName("data_classification") + .attributeScope(AttributeDefinition.AttributeScope.RESOURCE) + .attributeType(AttributeDefinition.AttributeType.STRING) + .isActive(true) + .build(); + + when(assignmentRepository.findByAttributeDefinitionAndTargetTypeAndTargetIdAndIsActiveTrue( + any(), any(), anyString())) + .thenReturn(Optional.empty()); + + AttributeAssignment savedAssignment = AttributeAssignment.builder() + .id(1L) + .attributeDefinition(definition) + .targetType(AttributeAssignment.TargetType.ENDPOINT) + .targetId("/api/sensitive") + .attributeValue("confidential") + .source(AttributeAssignment.AssignmentSource.SENTRIUS) + .isActive(true) + .build(); + + when(assignmentRepository.save(any(AttributeAssignment.class))) + .thenReturn(savedAssignment); + + // Act - Assign attribute to endpoint (not user) + AttributeAssignment result = attributeManagementService.assignAttribute( + definition, + AttributeAssignment.TargetType.ENDPOINT, + "/api/sensitive", + "confidential", + AttributeAssignment.AssignmentSource.SENTRIUS, + false + ); + + // Assert + assertNotNull(result); + assertEquals(AttributeAssignment.TargetType.ENDPOINT, result.getTargetType()); + + // Verify AttributeAssignment was saved + verify(assignmentRepository).save(any(AttributeAssignment.class)); + + // Verify UserAttribute was NOT created for non-user targets + verify(userAttributeRepository, never()).save(any(UserAttribute.class)); + } +} diff --git a/enterprise-agent/README-ABAC-AGENT.md b/enterprise-agent/README-ABAC-AGENT.md new file mode 100644 index 00000000..00897bd2 --- /dev/null +++ b/enterprise-agent/README-ABAC-AGENT.md @@ -0,0 +1,396 @@ +# ABAC Chat Interface Agent + +## Overview + +The ABAC (Attribute-Based Access Control) Chat Interface Agent is an intelligent agent that evaluates user attribute access requests based on available information, justification, and security policies. It uses LLM-powered evaluation to determine whether users should have access to specific attributes and manages attribute assignments with time-based expiration. + +## Features + +- **Intelligent Evaluation**: Uses LLM to assess attribute access requests based on justification strength, user context, and security implications +- **Time-Based Expiration**: Supports automatic expiration of attribute assignments after a specified duration +- **Memory Management**: Maintains history of evaluations, assignments, and expiry times +- **Automatic Revocation**: Periodically checks and revokes expired attributes +- **Pod Deployment**: Deployed as a Kubernetes pod using the existing launcher strategy + +## Architecture + +The ABAC agent consists of: + +1. **AbacVerbs**: Service providing 5 core verbs for attribute management +2. **abac-helper.yaml**: Configuration file defining agent behavior and context +3. **Pod Integration**: Deploys via PodLauncherService with "abac" agent type +4. **Memory System**: Uses agent short-term memory for tracking assignments and expiry + +## Deployment + +### Prerequisites + +- Kubernetes cluster with Sentrius installed +- PostgreSQL database with ABAC tables configured +- Keycloak authentication server +- OpenTelemetry endpoint for observability + +### Creating an ABAC Agent + +Using the enterprise agent or API, create an ABAC agent: + +```json +{ + "agentName": "abac-evaluator", + "context": "Evaluate and manage user attribute access requests for the organization", + "agentType": "abac" +} +``` + +The agent will be deployed as a Kubernetes pod and will be available for chat interactions. + +### Manual Deployment + +If deploying manually via kubectl: + +```bash +# Ensure the ABAC configuration is in the ConfigMap +kubectl get configmap sentrius-agents-config -n dev + +# Create the agent via API +curl -X POST http://localhost:8080/api/v1/agents \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agentName": "abac-evaluator", + "agentType": "abac", + "agentContextId": "" + }' +``` + +## Available Verbs + +### 1. evaluate_attribute_access + +Evaluates whether a user should have access to a specific attribute based on justification and context. + +**Parameters:** +```json +{ + "userId": "user123", + "attributeName": "high_security_clearance", + "justification": "User needs access to classified documents for Project Phoenix", + "requestingAgent": "security-agent" +} +``` + +**Returns:** +```json +{ + "decision": "APPROVED" | "DENIED" | "NEEDS_MORE_INFO", + "reasoning": "Detailed explanation of the decision", + "confidence": 0.85, + "suggestedExpiryHours": 24, + "additionalQuestionsNeeded": ["question1", "question2"], + "userId": "user123", + "attributeName": "high_security_clearance", + "alreadyHasAttribute": false +} +``` + +### 2. assign_user_attribute + +Assigns an attribute to a user with optional expiry time. + +**Parameters:** +```json +{ + "userId": "user123", + "attributeName": "high_security_clearance", + "attributeValue": "level_3", + "expiryHours": 24, + "reason": "Approved by security team for Project Phoenix" +} +``` + +**Returns:** +```json +{ + "success": true, + "userId": "user123", + "attributeName": "high_security_clearance", + "attributeValue": "level_3", + "assignedAt": "2026-01-03T13:00:00", + "expiresAt": "2026-01-04T13:00:00", + "expiryHours": 24, + "assignmentDetails": { ... } +} +``` + +### 3. revoke_user_attribute + +Revokes an attribute from a user. + +**Parameters:** +```json +{ + "userId": "user123", + "attributeName": "high_security_clearance", + "reason": "Project completed, access no longer needed" +} +``` + +**Returns:** +```json +{ + "success": true, + "userId": "user123", + "attributeName": "high_security_clearance", + "revokedAt": "2026-01-03T14:00:00", + "reason": "Project completed, access no longer needed" +} +``` + +### 4. list_user_attributes + +Lists all active attributes assigned to a user. + +**Parameters:** +```json +{ + "userId": "user123" +} +``` + +**Returns:** +```json +{ + "userId": "user123", + "count": 3, + "attributes": [ + { + "attributeName": "department", + "attributeValue": "engineering", + "id": 101 + }, + { + "attributeName": "clearance_level", + "attributeValue": "level_2", + "id": 102 + }, + { + "attributeName": "project_access", + "attributeValue": "phoenix", + "id": 103 + } + ] +} +``` + +### 5. check_expired_attributes + +Checks for expired attributes in agent memory and revokes them. + +**Parameters:** None + +**Returns:** +```json +{ + "checkedAt": "2026-01-04T13:05:00", + "revokedCount": 2, + "revokedAttributes": [ + { + "userId": "user123", + "attributeName": "temporary_clearance", + "assignmentId": 105, + "expiredAt": "2026-01-04T13:00:00", + "revokedAt": "2026-01-04T13:05:00" + }, + { + "userId": "user456", + "attributeName": "emergency_access", + "assignmentId": 106, + "expiredAt": "2026-01-04T12:00:00", + "revokedAt": "2026-01-04T13:05:00" + } + ] +} +``` + +## Usage Examples + +### Example 1: Requesting Attribute Access + +**User:** "I need high security clearance to access the Phoenix project documents." + +**Agent Evaluation:** +1. Calls `list_user_attributes` to check current attributes +2. Calls `evaluate_attribute_access` with the user's justification +3. Based on LLM evaluation, either: + - **APPROVED**: Calls `assign_user_attribute` with appropriate expiry (e.g., 24 hours) + - **DENIED**: Provides reasoning and asks user to contact security team + - **NEEDS_MORE_INFO**: Asks clarifying questions + +**Agent Response:** +``` +Your request for high security clearance has been approved for 24 hours based on your +involvement in Project Phoenix. The attribute 'high_security_clearance' with value 'level_3' +has been assigned to your account. This access will automatically expire on 2026-01-04 at 13:00:00. + +Please ensure you complete your work within this timeframe. If you need extended access, +please submit a new request with justification. +``` + +### Example 2: Checking Attribute Status + +**User:** "What attributes do I currently have?" + +**Agent:** +1. Calls `list_user_attributes` for the user +2. Formats and presents the results + +**Agent Response:** +``` +You currently have 3 active attributes: +1. department: engineering +2. clearance_level: level_2 +3. project_access: phoenix + +Your 'high_security_clearance' attribute expired on 2026-01-04 at 13:00:00 and has been +automatically revoked. +``` + +### Example 3: Periodic Expiration Check + +The ABAC agent automatically calls `check_expired_attributes` at the start of each conversation +session to ensure expired attributes are revoked. This can also be triggered manually or via +scheduled tasks. + +## Configuration + +### Agent Context (abac-helper.yaml) + +The agent's behavior is defined in `abac-helper.yaml`: + +```yaml +description: "ABAC agent for evaluating and managing user attribute access." +context: | + You are an ABAC (Attribute-Based Access Control) agent responsible for evaluating user + requests for attribute access and managing attribute assignments with time-based expiration. + + Your responsibilities: + 1. EVALUATE ACCESS REQUESTS: When users request access to attributes, evaluate their + justification using the evaluate_attribute_access verb. + + 2. ASSIGN ATTRIBUTES: If a request is approved, use assign_user_attribute to grant the + attribute. Always specify an appropriate expiry time based on sensitivity: + - Low sensitivity: 168 hours (1 week) + - Medium sensitivity: 72 hours (3 days) + - High sensitivity: 24 hours (1 day) + - Critical/temporary: 4-8 hours + + 3. REVOKE ACCESS: Use revoke_user_attribute when access should be removed immediately. + + 4. MONITOR EXPIRATION: Periodically use check_expired_attributes to ensure expired + attributes are revoked. + + 5. LIST ATTRIBUTES: Use list_user_attributes to view a user's current attributes. + + IMPORTANT GUIDELINES: + - ALWAYS use check_expired_attributes at the start of each conversation session + - ALWAYS provide clear reasoning for your access decisions + - NEVER grant indefinite access - always set an expiry time + - Store evaluation history for audit purposes + - Follow the principle of least privilege +``` + +### Expiry Time Guidelines + +The agent is configured to use these default expiry times based on attribute sensitivity: + +| Sensitivity Level | Expiry Time | Use Case | +|------------------|-------------|----------| +| Low | 168 hours (1 week) | Department, project memberships | +| Medium | 72 hours (3 days) | Elevated permissions, resource access | +| High | 24 hours (1 day) | Security clearances, sensitive data | +| Critical/Temporary | 4-8 hours | Emergency access, one-time operations | + +## Memory Management + +The ABAC agent uses three types of memory keys: + +1. **Assignment Keys**: `abac_assignment_{userId}_{attributeName}` + - Stores assignment metadata (value, assigned time, reason) + +2. **Expiry Keys**: `abac_expiry_{userId}_{attributeName}` + - Stores expiry information for automatic revocation + +3. **Evaluation History Keys**: `abac_eval_history_{userId}_{attributeName}` + - Stores evaluation history for audit and context + +These memory items are stored in the agent's short-term memory and persist across +conversations within the same execution context. + +## Integration with Existing ABAC System + +The ABAC agent integrates with Sentrius's existing ABAC system: + +- Uses `AttributeManagementService` for attribute operations +- Stores assignments in the `attribute_assignments` table +- Leverages `AttributeDefinition` for attribute metadata +- Syncs with Keycloak when needed (via `syncToKeycloak` flag) + +## Security Considerations + +1. **LLM Evaluation**: The agent uses LLM to evaluate requests, providing intelligent + decision-making but requiring clear guidelines in the agent context. + +2. **Audit Trail**: All evaluations, assignments, and revocations are logged and stored + in memory for audit purposes. + +3. **Automatic Expiration**: Time-based expiration ensures temporary access doesn't + become permanent. + +4. **Memory Persistence**: Expiry information is stored in agent memory to survive + restarts within the same execution context. + +5. **Access Control**: The agent itself requires appropriate permissions to assign + and revoke attributes via the ABAC API. + +## Troubleshooting + +### Agent Not Responding + +Check agent status: +```bash +kubectl get pods -n dev -l agentId=abac-evaluator +kubectl logs -n dev -l agentId=abac-evaluator +``` + +### Attributes Not Expiring + +1. Verify `check_expired_attributes` is being called +2. Check agent memory for expiry keys: + ``` + User: "Show me the contents of your memory related to expiry" + ``` +3. Verify database connectivity and ABAC API accessibility + +### Permission Denied + +Ensure the agent has appropriate trust policy with endpoints: +- `/api/v1/abac/user-attributes` +- `/api/v1/abac/user-attributes/user/{userId}` +- `/api/v1/abac/attribute-definitions` + +## Future Enhancements + +- [ ] Scheduled background task for periodic expiry checking +- [ ] Integration with approval workflows +- [ ] Policy-based automatic expiry time calculation +- [ ] Multi-stage approval for sensitive attributes +- [ ] Notification system for expiring attributes +- [ ] Dashboard for monitoring attribute assignments + +## Support + +For issues or questions: +1. Check agent logs: `kubectl logs -n dev -l agentId=abac-evaluator` +2. Review agent memory and evaluation history +3. Contact the Sentrius security team +4. File an issue in the Sentrius repository diff --git a/enterprise-agent/src/main/java/io/sentrius/agent/analysis/agents/verbs/AbacVerbs.java b/enterprise-agent/src/main/java/io/sentrius/agent/analysis/agents/verbs/AbacVerbs.java new file mode 100644 index 00000000..0c7f8fce --- /dev/null +++ b/enterprise-agent/src/main/java/io/sentrius/agent/analysis/agents/verbs/AbacVerbs.java @@ -0,0 +1,541 @@ +package io.sentrius.agent.analysis.agents.verbs; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.sentrius.sso.core.dto.agents.AgentExecutionContextDTO; +import io.sentrius.sso.core.dto.agents.AgentExecution; +import io.sentrius.sso.core.exceptions.ZtatException; +import io.sentrius.sso.core.model.verbs.Verb; +import io.sentrius.sso.core.services.agents.AgentClientService; +import io.sentrius.sso.core.services.agents.LLMService; +import io.sentrius.sso.core.services.agents.ZeroTrustClientService; +import io.sentrius.sso.core.utils.JsonUtil; +import io.sentrius.sso.genai.Message; +import io.sentrius.sso.genai.Response; +import io.sentrius.sso.genai.model.LLMRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; + +/** + * ABAC (Attribute-Based Access Control) Verbs for evaluating and managing user attribute access. + * This service provides functionality to evaluate attribute access requests based on justification, + * assign/revoke attributes with time-based expiration, and maintain memory of attribute assignments. + */ +@Service +@Slf4j +public class AbacVerbs extends VerbBase { + + private final ZeroTrustClientService zeroTrustClientService; + private final LLMService llmService; + + // Memory key prefixes for attribute management + private static final String ATTRIBUTE_ASSIGNMENT_PREFIX = "abac_assignment_"; + private static final String ATTRIBUTE_EXPIRY_PREFIX = "abac_expiry_"; + private static final String EVALUATION_HISTORY_PREFIX = "abac_eval_history_"; + + // Default LLM model for evaluations - can be configured + @Value("${agent.abac.llm.model:gpt-4o-mini}") + private String llmModel; + + public AbacVerbs( + @Value("${agent.ai.config}") String agentConfigFile, + @Value("${agent.ai.context.db.id:none}") String agentDatabaseContext, + ZeroTrustClientService zeroTrustClientService, + LLMService llmService, + AgentClientService agentService + ) throws JsonProcessingException { + super(agentConfigFile, agentDatabaseContext, agentService); + this.zeroTrustClientService = zeroTrustClientService; + this.llmService = llmService; + log.info("AbacVerbs initialized for ABAC attribute management"); + } + + /** + * Helper method to safely extract and clean content from LLM responses. + * Handles common code block markers and validates string operations. + */ + private String cleanLlmContent(String content) { + if (content == null || content.isEmpty()) { + return ""; + } + + // Remove code block markers if present + if (content.startsWith("```json") && content.length() > 10) { + content = content.substring(7); + if (content.endsWith("```") && content.length() > 3) { + content = content.substring(0, content.length() - 3); + } + } else if (content.startsWith("```") && content.length() > 6) { + content = content.substring(3); + if (content.endsWith("```") && content.length() > 3) { + content = content.substring(0, content.length() - 3); + } + } + + return content.trim(); + } + + /** + * Evaluates whether a user should have access to specific attributes based on justification and context. + * Uses LLM to assess the request and available data to make an access decision. + */ + @Verb( + name = "evaluate_attribute_access", + returnType = ObjectNode.class, + description = "Evaluates whether a user should have access to specific attributes based on justification " + + "and available data. Returns decision (APPROVED/DENIED/NEEDS_MORE_INFO) with reasoning.", + exampleJson = "{ \"userId\": \"user123\", \"attributeName\": \"high_security_clearance\", " + + "\"justification\": \"User needs access to classified documents for project X\", " + + "\"requestingAgent\": \"agent-name\" }", + requiresTokenManagement = true, + argName = "access_request" + ) + public ObjectNode evaluateAttributeAccess(AgentExecution execution, AgentExecutionContextDTO context) + throws ZtatException, JsonProcessingException { + + log.info("Evaluating attribute access request"); + + // Extract parameters + Optional userIdNode = context.getExecutionArgument("access_request", "userId"); + Optional attributeNode = context.getExecutionArgument("access_request", "attributeName"); + Optional justificationNode = context.getExecutionArgument("access_request", "justification"); + + String userId = userIdNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("userId is required")); + String attributeName = attributeNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("attributeName is required")); + String justification = justificationNode.map(JsonNode::asText).orElse(""); + + // Get current user attributes from API + String userAttributesResponse = zeroTrustClientService.callGetOnApi( + execution, + "/api/v1/abac/user-attributes/user/" + userId + ); + JsonNode currentAttributes = JsonUtil.MAPPER.readTree(userAttributesResponse); + + // Get attribute definition from API + String attributeDefsResponse = zeroTrustClientService.callGetOnApi( + execution, + "/api/v1/abac/attribute-definitions" + ); + JsonNode attributeDefs = JsonUtil.MAPPER.readTree(attributeDefsResponse); + + // Check if user already has the attribute + boolean alreadyHasAttribute = false; + if (currentAttributes.isArray()) { + for (JsonNode attr : currentAttributes) { + if (attr.has("attributeName") && + attributeName.equals(attr.get("attributeName").asText())) { + alreadyHasAttribute = true; + break; + } + } + } + + // Retrieve evaluation history from memory + String historyKey = EVALUATION_HISTORY_PREFIX + userId + "_" + attributeName; + String history = context.getFromMemory(historyKey).orElse("No previous evaluation history"); + + // Build LLM prompt for evaluation + List messages = new ArrayList<>(); + messages.add(Message.builder() + .role("system") + .content("You are an ABAC (Attribute-Based Access Control) evaluator. Your role is to assess " + + "whether a user should be granted access to specific attributes based on their justification " + + "and available context. Consider:\n" + + "1. The strength and validity of the justification\n" + + "2. Current attributes the user already has\n" + + "3. Previous evaluation history\n" + + "4. Security implications of granting the attribute\n\n" + + "Respond in JSON format: {\n" + + " \"decision\": \"APPROVED\" | \"DENIED\" | \"NEEDS_MORE_INFO\",\n" + + " \"reasoning\": \"Detailed explanation of the decision\",\n" + + " \"confidence\": 0.0-1.0,\n" + + " \"suggestedExpiryHours\": (optional, only if APPROVED),\n" + + " \"additionalQuestionsNeeded\": [\"question1\", \"question2\"] (optional, if NEEDS_MORE_INFO)\n" + + "}") + .build()); + + messages.add(Message.builder() + .role("user") + .content(String.format( + "Evaluate attribute access request:\n" + + "User ID: %s\n" + + "Requested Attribute: %s\n" + + "Justification: %s\n" + + "User already has this attribute: %s\n" + + "Current attributes: %s\n" + + "Previous evaluation history: %s", + userId, attributeName, justification, alreadyHasAttribute, + currentAttributes.toString(), history + )) + .build()); + + // Query LLM + LLMRequest chatRequest = LLMRequest.builder() + .model(llmModel) + .messages(messages) + .build(); + + String llmResponse = llmService.askQuestion(execution, chatRequest); + Response response = JsonUtil.MAPPER.readValue(llmResponse, Response.class); + + ObjectNode result = JsonUtil.MAPPER.createObjectNode(); + + for (Response.OutputItem choice : response.getOutputItems()) { + String content = choice.getContent().stream() + .filter(c -> "output_text".equals(c.getType()) || "text".equals(c.getType())) + .map(c -> c.getText()) + .findFirst() + .orElse(""); + + content = cleanLlmContent(content); + + if (content.isEmpty()) { + continue; + } + + JsonNode evaluation = JsonUtil.MAPPER.readTree(content); + result.setAll((ObjectNode) evaluation); + + // Store evaluation in history + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + String historyEntry = String.format("[%s] Decision: %s, Reasoning: %s", + timestamp, + evaluation.path("decision").asText(), + evaluation.path("reasoning").asText()); + context.addToMemory(historyKey, history + "\n" + historyEntry); + } + + result.put("userId", userId); + result.put("attributeName", attributeName); + result.put("alreadyHasAttribute", alreadyHasAttribute); + + log.info("Attribute access evaluation complete: {} for user {} requesting {}", + result.path("decision").asText(), userId, attributeName); + + return result; + } + + /** + * Assigns an attribute to a user with optional expiry time. + * Stores assignment in the ABAC system and tracks expiry in agent memory. + */ + @Verb( + name = "assign_user_attribute", + returnType = ObjectNode.class, + description = "Assigns an attribute to a user with optional expiry time. " + + "If expiryHours is provided, the attribute will automatically be revoked after that time.", + exampleJson = "{ \"userId\": \"user123\", \"attributeName\": \"high_security_clearance\", " + + "\"attributeValue\": \"level_3\", \"expiryHours\": 24, \"reason\": \"Approved by security team\" }", + requiresTokenManagement = true, + argName = "assignment" + ) + public ObjectNode assignUserAttribute(AgentExecution execution, AgentExecutionContextDTO context) + throws ZtatException, JsonProcessingException { + + log.info("Assigning attribute to user"); + + // Extract parameters + Optional userIdNode = context.getExecutionArgument("assignment", "userId"); + Optional attributeNode = context.getExecutionArgument("assignment", "attributeName"); + Optional valueNode = context.getExecutionArgument("assignment", "attributeValue"); + Optional expiryNode = context.getExecutionArgument("assignment", "expiryHours"); + Optional reasonNode = context.getExecutionArgument("assignment", "reason"); + + String userId = userIdNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("userId is required")); + String attributeName = attributeNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("attributeName is required")); + String attributeValue = valueNode.map(JsonNode::asText).orElse("true"); + Integer expiryHours = expiryNode.map(JsonNode::asInt).orElse(null); + String reason = reasonNode.map(JsonNode::asText).orElse("Assigned by ABAC agent"); + + // Calculate expiry times if provided + LocalDateTime validFrom = LocalDateTime.now(); + LocalDateTime validUntil = null; + if (expiryHours != null && expiryHours > 0) { + validUntil = validFrom.plusHours(expiryHours); + } + + // Build request payload + ObjectNode assignmentRequest = JsonUtil.MAPPER.createObjectNode(); + assignmentRequest.put("targetType", "USER"); + assignmentRequest.put("targetId", userId); + assignmentRequest.put("attributeName", attributeName); + assignmentRequest.put("attributeValue", attributeValue); + assignmentRequest.put("syncToKeycloak", false); + + if (validFrom != null) { + assignmentRequest.put("validFrom", validFrom.format(DateTimeFormatter.ISO_DATE_TIME)); + } + if (validUntil != null) { + assignmentRequest.put("validUntil", validUntil.format(DateTimeFormatter.ISO_DATE_TIME)); + } + + // Call API to create assignment + String response = zeroTrustClientService.callPostOnApi( + execution, + "/api/v1/abac/user-attributes", + assignmentRequest + ); + + JsonNode assignmentResponse = JsonUtil.MAPPER.readTree(response); + + // Store assignment in memory with expiry tracking + String assignmentKey = ATTRIBUTE_ASSIGNMENT_PREFIX + userId + "_" + attributeName; + String assignmentInfo = String.format( + "{\"userId\":\"%s\",\"attributeName\":\"%s\",\"value\":\"%s\",\"assignedAt\":\"%s\",\"reason\":\"%s\"}", + userId, attributeName, attributeValue, validFrom.format(DateTimeFormatter.ISO_DATE_TIME), reason + ); + context.addToMemory(assignmentKey, assignmentInfo); + + // Store expiry time in memory if applicable + if (validUntil != null) { + String expiryKey = ATTRIBUTE_EXPIRY_PREFIX + userId + "_" + attributeName; + String expiryInfo = String.format( + "{\"userId\":\"%s\",\"attributeName\":\"%s\",\"expiryTime\":\"%s\",\"assignmentId\":%d}", + userId, attributeName, validUntil.format(DateTimeFormatter.ISO_DATE_TIME), + assignmentResponse.path("id").asLong() + ); + context.addToMemory(expiryKey, expiryInfo); + log.info("Attribute {} will expire for user {} at {}", attributeName, userId, validUntil); + } + + ObjectNode result = JsonUtil.MAPPER.createObjectNode(); + result.put("success", true); + result.put("userId", userId); + result.put("attributeName", attributeName); + result.put("attributeValue", attributeValue); + result.put("assignedAt", validFrom.format(DateTimeFormatter.ISO_DATE_TIME)); + if (validUntil != null) { + result.put("expiresAt", validUntil.format(DateTimeFormatter.ISO_DATE_TIME)); + result.put("expiryHours", expiryHours); + } + result.set("assignmentDetails", assignmentResponse); + + log.info("Assigned attribute {} to user {}", attributeName, userId); + + return result; + } + + /** + * Revokes an attribute from a user. + */ + @Verb( + name = "revoke_user_attribute", + returnType = ObjectNode.class, + description = "Revokes an attribute from a user. Removes the attribute assignment from the system.", + exampleJson = "{ \"userId\": \"user123\", \"attributeName\": \"high_security_clearance\", " + + "\"reason\": \"Access no longer needed\" }", + requiresTokenManagement = true, + argName = "revocation" + ) + public ObjectNode revokeUserAttribute(AgentExecution execution, AgentExecutionContextDTO context) + throws ZtatException, JsonProcessingException { + + log.info("Revoking attribute from user"); + + // Extract parameters + Optional userIdNode = context.getExecutionArgument("revocation", "userId"); + Optional attributeNode = context.getExecutionArgument("revocation", "attributeName"); + Optional reasonNode = context.getExecutionArgument("revocation", "reason"); + + String userId = userIdNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("userId is required")); + String attributeName = attributeNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("attributeName is required")); + String reason = reasonNode.map(JsonNode::asText).orElse("Revoked by ABAC agent"); + + // Get user's current attributes to find the assignment ID + String userAttributesResponse = zeroTrustClientService.callGetOnApi( + execution, + "/api/v1/abac/user-attributes/user/" + userId + ); + JsonNode currentAttributes = JsonUtil.MAPPER.readTree(userAttributesResponse); + + Long assignmentId = null; + if (currentAttributes.isArray()) { + for (JsonNode attr : currentAttributes) { + if (attr.has("attributeName") && + attributeName.equals(attr.get("attributeName").asText())) { + assignmentId = attr.path("id").asLong(); + break; + } + } + } + + if (assignmentId == null) { + ObjectNode errorResult = JsonUtil.MAPPER.createObjectNode(); + errorResult.put("success", false); + errorResult.put("error", "Attribute not found for user"); + errorResult.put("userId", userId); + errorResult.put("attributeName", attributeName); + return errorResult; + } + + // Call API to delete assignment + zeroTrustClientService.callDeleteOnApi( + execution, + "/api/v1/abac/user-attributes/" + assignmentId + ); + + // Remove from memory + String assignmentKey = ATTRIBUTE_ASSIGNMENT_PREFIX + userId + "_" + attributeName; + String expiryKey = ATTRIBUTE_EXPIRY_PREFIX + userId + "_" + attributeName; + context.removeFromMemory(assignmentKey); + context.removeFromMemory(expiryKey); + + ObjectNode result = JsonUtil.MAPPER.createObjectNode(); + result.put("success", true); + result.put("userId", userId); + result.put("attributeName", attributeName); + result.put("revokedAt", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)); + result.put("reason", reason); + + log.info("Revoked attribute {} from user {}", attributeName, userId); + + return result; + } + + /** + * Lists all attributes for a user. + */ + @Verb( + name = "list_user_attributes", + returnType = ObjectNode.class, + description = "Lists all active attributes assigned to a user.", + exampleJson = "{ \"userId\": \"user123\" }", + requiresTokenManagement = true, + argName = "user_query" + ) + public ObjectNode listUserAttributes(AgentExecution execution, AgentExecutionContextDTO context) + throws ZtatException, JsonProcessingException { + + log.info("Listing user attributes"); + + // Extract parameters + Optional userIdNode = context.getExecutionArgument("user_query", "userId"); + String userId = userIdNode.map(JsonNode::asText).orElseThrow( + () -> new IllegalArgumentException("userId is required")); + + // Get user's current attributes + String userAttributesResponse = zeroTrustClientService.callGetOnApi( + execution, + "/api/v1/abac/user-attributes/user/" + userId + ); + JsonNode attributes = JsonUtil.MAPPER.readTree(userAttributesResponse); + + ObjectNode result = JsonUtil.MAPPER.createObjectNode(); + result.put("userId", userId); + result.set("attributes", attributes); + + if (attributes.isArray()) { + result.put("count", attributes.size()); + } else { + result.put("count", 0); + } + + log.info("Listed {} attributes for user {}", result.get("count"), userId); + + return result; + } + + /** + * Checks for expired attributes and revokes them. + * This should be called periodically by the agent or on-demand. + */ + @Verb( + name = "check_expired_attributes", + returnType = ObjectNode.class, + description = "Checks agent memory for expired attribute assignments and revokes them. " + + "Returns list of revoked attributes.", + requiresTokenManagement = true + ) + public ObjectNode checkExpiredAttributes(AgentExecution execution, AgentExecutionContextDTO context) + throws ZtatException, JsonProcessingException { + + log.info("Checking for expired attributes"); + + ArrayNode revokedAttributes = JsonUtil.MAPPER.createArrayNode(); + LocalDateTime now = LocalDateTime.now(); + + // Get all expiry entries from memory + Map memory = context.getAgentShortTermMemory(); + List expiredKeys = new ArrayList<>(); + + for (Map.Entry entry : memory.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(ATTRIBUTE_EXPIRY_PREFIX)) { + try { + String valueStr = entry.getValue().isTextual() + ? entry.getValue().asText() + : entry.getValue().toString(); + JsonNode expiryData = JsonUtil.MAPPER.readTree(valueStr); + String expiryTimeStr = expiryData.path("expiryTime").asText(); + LocalDateTime expiryTime = LocalDateTime.parse(expiryTimeStr, DateTimeFormatter.ISO_DATE_TIME); + + if (now.isAfter(expiryTime)) { + String userId = expiryData.path("userId").asText(); + String attributeName = expiryData.path("attributeName").asText(); + Long assignmentId = expiryData.path("assignmentId").asLong(); + + log.info("Found expired attribute {} for user {}, revoking...", attributeName, userId); + + try { + // Call API to delete assignment + zeroTrustClientService.callDeleteOnApi( + execution, + "/api/v1/abac/user-attributes/" + assignmentId + ); + + ObjectNode revokedInfo = JsonUtil.MAPPER.createObjectNode(); + revokedInfo.put("userId", userId); + revokedInfo.put("attributeName", attributeName); + revokedInfo.put("assignmentId", assignmentId); + revokedInfo.put("expiredAt", expiryTimeStr); + revokedInfo.put("revokedAt", now.format(DateTimeFormatter.ISO_DATE_TIME)); + revokedAttributes.add(revokedInfo); + + // Mark keys for removal + expiredKeys.add(key); + expiredKeys.add(ATTRIBUTE_ASSIGNMENT_PREFIX + userId + "_" + attributeName); + + } catch (Exception e) { + log.error("Failed to revoke expired attribute {} for user {}: {}", + attributeName, userId, e.getMessage()); + } + } + } catch (DateTimeParseException e) { + log.error("Failed to parse expiry time for key {}: {}", key, e.getMessage()); + } catch (Exception e) { + log.error("Error processing expiry entry {}: {}", key, e.getMessage()); + } + } + } + + // Remove expired entries from memory + for (String key : expiredKeys) { + context.removeFromMemory(key); + } + + ObjectNode result = JsonUtil.MAPPER.createObjectNode(); + result.put("checkedAt", now.format(DateTimeFormatter.ISO_DATE_TIME)); + result.put("revokedCount", revokedAttributes.size()); + result.set("revokedAttributes", revokedAttributes); + + log.info("Expired attributes check complete. Revoked {} attributes", revokedAttributes.size()); + + return result; + } +} diff --git a/enterprise-agent/src/main/resources/abac-helper.yaml b/enterprise-agent/src/main/resources/abac-helper.yaml new file mode 100644 index 00000000..22c5d218 --- /dev/null +++ b/enterprise-agent/src/main/resources/abac-helper.yaml @@ -0,0 +1,48 @@ +description: "ABAC (Attribute-Based Access Control) agent for evaluating and managing user attribute access." +context: | + You are an ABAC (Attribute-Based Access Control) agent responsible for evaluating user requests for attribute access + and managing attribute assignments with time-based expiration. + + Your responsibilities: + 1. EVALUATE ACCESS REQUESTS: When users request access to attributes, evaluate their justification using the + evaluate_attribute_access verb. Consider the strength of justification, user's current attributes, and security implications. + + 2. ASSIGN ATTRIBUTES: If a request is approved, use assign_user_attribute to grant the attribute. Always specify + an appropriate expiry time (expiryHours) based on the nature of the access and the sensitivity of the attribute. + - Low sensitivity attributes: 168 hours (1 week) + - Medium sensitivity attributes: 72 hours (3 days) + - High sensitivity attributes: 24 hours (1 day) + - Critical/temporary access: 4-8 hours + + 3. REVOKE ACCESS: Use revoke_user_attribute when access should be removed immediately, such as when: + - A user's role changes + - Access was granted in error + - Security concerns arise + + 4. MONITOR EXPIRATION: Periodically use check_expired_attributes to ensure expired attributes are revoked. + You should do this at the start of each conversation and when explicitly asked. + + 5. LIST ATTRIBUTES: Use list_user_attributes to view a user's current attributes when needed for evaluation. + + IMPORTANT GUIDELINES: + - ALWAYS use check_expired_attributes at the start of each conversation session + - ALWAYS provide clear reasoning for your access decisions + - NEVER grant indefinite access - always set an expiry time appropriate to the request + - Store evaluation history and reasoning in your responses for audit purposes + - If you need more information to make a decision, ask clarifying questions + - Consider the principle of least privilege - grant the minimum necessary access + - Use lookup_agent_memory or search_agent_memory_semantic to recall previous decisions about users + + When a user asks about attribute access: + 1. First check if they already have the attribute using list_user_attributes + 2. If not, evaluate their request using evaluate_attribute_access + 3. Based on the evaluation, either approve with assign_user_attribute or deny with explanation + 4. If approved, clearly communicate the expiry time to the user + + Return responses in the following format: + { + "previousOperation": "", + "nextOperation": "", + "summaryForLLM": "", + "responseForUser": "" + } diff --git a/sentrius-chart/templates/configmap.yaml b/sentrius-chart/templates/configmap.yaml index 3d070ab5..37142a7b 100644 --- a/sentrius-chart/templates/configmap.yaml +++ b/sentrius-chart/templates/configmap.yaml @@ -386,6 +386,7 @@ data: sentrius.rlhf.feedback.api.url={{ .Values.sentriusDomain }} api-application.properties: | org.springframework.context.ApplicationListener=your.package.DbEnvPrinter + sentrius.tenant={{ .Values.tenant }} keystore.file=sso.jceks keystore.password=${KEYSTORE_PASSWORD} keystore.alias=KEYBOX-ENCRYPTION_KEY From 8f85bac4195a0fe9b2e98a45755c6ed914d603cb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:29:11 -0500 Subject: [PATCH 2/6] Add AI-powered tooltip system with codebase search and LLM integration (#282) * Initial plan * Add tooltip controller with DTOs, service, and tests - Created TooltipController with /describe and /chat endpoints - Added DTOs for tooltip requests and responses - Implemented TooltipService for AI-powered tooltips - Added CodebaseIndexingService for indexing docs and code - Created comprehensive unit tests - All tests passing Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> * Add admin indexing endpoint and comprehensive documentation - Added /admin/index endpoint for manual codebase indexing - Added permission check for admin-only indexing trigger - Created comprehensive TOOLTIP_SYSTEM.md documentation - Documented API endpoints, configuration, architecture, and usage - Included troubleshooting guide and future enhancements Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> * Fix TooltipControllerTest to include CodebaseIndexingService mock - Added missing CodebaseIndexingService mock in test setup - Updated constructor call with all 5 required parameters - All 7 tests passing successfully Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> * Address code review feedback - Made LLM model configurable via application properties - Added comprehensive documentation for deprecated method usage - Improved glob pattern matching documentation - Fixed Lombok annotation imports to use short form - Updated documentation with new configuration option Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> --- .../controllers/api/TooltipController.java | 210 +++++++++++ .../api/TooltipControllerTest.java | 262 +++++++++++++ .../core/dto/tooltip/TooltipChatRequest.java | 31 ++ .../core/dto/tooltip/TooltipChatResponse.java | 36 ++ .../dto/tooltip/TooltipDescribeRequest.java | 72 ++++ .../dto/tooltip/TooltipDescribeResponse.java | 36 ++ .../tooltip/CodebaseIndexingService.java | 356 ++++++++++++++++++ .../core/services/tooltip/TooltipService.java | 346 +++++++++++++++++ docs/TOOLTIP_SYSTEM.md | 249 ++++++++++++ 9 files changed, 1598 insertions(+) create mode 100644 api/src/main/java/io/sentrius/sso/controllers/api/TooltipController.java create mode 100644 api/src/test/java/io/sentrius/sso/controllers/api/TooltipControllerTest.java create mode 100644 core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatRequest.java create mode 100644 core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatResponse.java create mode 100644 core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeRequest.java create mode 100644 core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeResponse.java create mode 100644 dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/CodebaseIndexingService.java create mode 100644 dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/TooltipService.java create mode 100644 docs/TOOLTIP_SYSTEM.md diff --git a/api/src/main/java/io/sentrius/sso/controllers/api/TooltipController.java b/api/src/main/java/io/sentrius/sso/controllers/api/TooltipController.java new file mode 100644 index 00000000..e7a19a51 --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/api/TooltipController.java @@ -0,0 +1,210 @@ +package io.sentrius.sso.controllers.api; + +import io.sentrius.sso.core.annotations.LimitAccess; +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.dto.tooltip.TooltipChatRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipChatResponse; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeResponse; +import io.sentrius.sso.core.dto.ztat.TokenDTO; +import io.sentrius.sso.core.model.security.enums.ApplicationAccessEnum; +import io.sentrius.sso.core.model.security.enums.SSHAccessEnum; +import io.sentrius.sso.core.model.users.User; +import io.sentrius.sso.core.model.verbs.Endpoint; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import io.sentrius.sso.core.services.tooltip.CodebaseIndexingService; +import io.sentrius.sso.core.services.tooltip.TooltipService; +import io.sentrius.sso.core.utils.AccessUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * REST Controller for AI-powered tooltip and contextual help features. + * Provides endpoints for getting descriptions of UI elements and chat-based assistance. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/tooltip") +public class TooltipController extends BaseController { + + private final TooltipService tooltipService; + private final CodebaseIndexingService indexingService; + + public TooltipController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService, + TooltipService tooltipService, + CodebaseIndexingService indexingService) { + super(userService, systemOptions, errorOutputService); + this.tooltipService = tooltipService; + this.indexingService = indexingService; + } + + /** + * Get AI-powered description for a UI element. + * Searches indexed codebase and documentation to provide contextual tooltips. + * + * @param request Element context information from the frontend + * @param httpRequest HTTP request for authentication + * @param httpResponse HTTP response + * @return AI-generated description of the element + */ + @PostMapping("/describe") + @Endpoint(description = "Get AI-powered description for a UI element") + @LimitAccess(applicationAccess = ApplicationAccessEnum.CAN_LOG_IN) + public ResponseEntity describe( + @RequestBody TooltipDescribeRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + try { + User operatingUser = getOperatingUser(httpRequest, httpResponse); + log.info("Tooltip describe request from user: {}", operatingUser.getUserId()); + + // Validate request + if (request.getContext() == null) { + return ResponseEntity.badRequest() + .body(TooltipDescribeResponse.builder() + .description("Invalid request: context is required") + .error("Context is required") + .success(false) + .build()); + } + + // Build token DTO for LLM service + TokenDTO tokenDTO = TokenDTO.builder().build(); + // Token will be populated from security context by LLM service + + // Generate description + TooltipDescribeResponse response = tooltipService.describeElement(request, tokenDTO); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Error processing tooltip describe request", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(TooltipDescribeResponse.builder() + .description("An error occurred while generating the description.") + .error(e.getMessage()) + .success(false) + .build()); + } + } + + /** + * Chat endpoint for conversational assistance about UI elements and features. + * + * @param request Chat message and optional element context + * @param httpRequest HTTP request for authentication + * @param httpResponse HTTP response + * @return AI-generated chat response + */ + @PostMapping("/chat") + @Endpoint(description = "Chat with AI assistant about UI elements and features") + @LimitAccess(applicationAccess = ApplicationAccessEnum.CAN_LOG_IN) + public ResponseEntity chat( + @RequestBody TooltipChatRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + try { + User operatingUser = getOperatingUser(httpRequest, httpResponse); + log.info("Tooltip chat request from user: {}", operatingUser.getUserId()); + + // Validate request + if (request.getMessage() == null || request.getMessage().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(TooltipChatResponse.builder() + .response("Invalid request: message is required") + .error("Message is required") + .success(false) + .build()); + } + + // Build token DTO for LLM service + TokenDTO tokenDTO = TokenDTO.builder().build(); + // Token will be populated from security context by LLM service + + // Generate chat response + TooltipChatResponse response = tooltipService.chat(request, tokenDTO); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Error processing tooltip chat request", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(TooltipChatResponse.builder() + .response("An error occurred while processing your message.") + .error(e.getMessage()) + .success(false) + .build()); + } + } + + /** + * Trigger manual indexing of codebase and documentation. + * Requires admin/system management permissions. + * + * @param httpRequest HTTP request for authentication + * @param httpResponse HTTP response + * @return Indexing result with statistics + */ + @PostMapping("/admin/index") + @Endpoint(description = "Trigger manual indexing of codebase and documentation") + @LimitAccess(applicationAccess = ApplicationAccessEnum.CAN_LOG_IN) + public ResponseEntity> triggerIndexing( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + + try { + User operatingUser = getOperatingUser(httpRequest, httpResponse); + + // Check if user has admin/system permissions + if (!AccessUtil.canAccess(operatingUser, SSHAccessEnum.CAN_MANAGE_SYSTEMS)) { + log.warn("Non-admin user {} attempted to trigger indexing", operatingUser.getUserId()); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of( + "success", false, + "error", "Admin privileges required to trigger indexing" + )); + } + + log.info("Indexing triggered by admin user: {}", operatingUser.getUserId()); + + // Run indexing + CodebaseIndexingService.IndexingResult result = indexingService.indexCodebase(); + + // Build response + Map response = new HashMap<>(); + response.put("success", result.isSuccess()); + response.put("message", result.getMessage()); + response.put("totalFiles", result.getTotalFiles()); + response.put("successCount", result.getSuccessCount()); + response.put("errorCount", result.getErrorCount()); + + if (result.getErrors() != null && !result.getErrors().isEmpty()) { + response.put("errors", result.getErrors()); + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Error triggering indexing", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of( + "success", false, + "error", "Failed to trigger indexing: " + e.getMessage() + )); + } + } +} diff --git a/api/src/test/java/io/sentrius/sso/controllers/api/TooltipControllerTest.java b/api/src/test/java/io/sentrius/sso/controllers/api/TooltipControllerTest.java new file mode 100644 index 00000000..57ab888e --- /dev/null +++ b/api/src/test/java/io/sentrius/sso/controllers/api/TooltipControllerTest.java @@ -0,0 +1,262 @@ +package io.sentrius.sso.controllers.api; + +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.dto.tooltip.TooltipChatRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipChatResponse; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeResponse; +import io.sentrius.sso.core.dto.ztat.TokenDTO; +import io.sentrius.sso.core.model.users.User; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import io.sentrius.sso.core.services.tooltip.CodebaseIndexingService; +import io.sentrius.sso.core.services.tooltip.TooltipService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for TooltipController + */ +@ExtendWith(MockitoExtension.class) +class TooltipControllerTest { + + @Mock + private TooltipService tooltipService; + + @Mock + private CodebaseIndexingService indexingService; + + @Mock + private UserService userService; + + @Mock + private SystemOptions systemOptions; + + @Mock + private ErrorOutputService errorOutputService; + + @Mock + private HttpServletRequest httpRequest; + + @Mock + private HttpServletResponse httpResponse; + + private TooltipController tooltipController; + + @BeforeEach + void setUp() { + tooltipController = new TooltipController( + userService, systemOptions, errorOutputService, tooltipService, indexingService); + + // Mock user service to return a valid user + User mockUser = new User(); + mockUser.setUserId("test-user"); + lenient().when(userService.getOperatingUser(any(), any(), any())) + .thenReturn(mockUser); + } + + @Test + void testDescribe_ValidRequest_ReturnsDescription() { + // Arrange + TooltipDescribeRequest.ElementContext context = TooltipDescribeRequest.ElementContext.builder() + .tagName("BUTTON") + .id("submit-btn") + .textContent("Submit") + .build(); + + TooltipDescribeRequest request = TooltipDescribeRequest.builder() + .context(context) + .timestamp(System.currentTimeMillis()) + .build(); + + TooltipDescribeResponse expectedResponse = TooltipDescribeResponse.builder() + .description("This button submits the form data.") + .message("This button submits the form data.") + .success(true) + .build(); + + when(tooltipService.describeElement(any(TooltipDescribeRequest.class), any(TokenDTO.class))) + .thenReturn(expectedResponse); + + // Act + ResponseEntity response = tooltipController.describe( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("This button submits the form data.", response.getBody().getDescription()); + verify(tooltipService).describeElement(any(TooltipDescribeRequest.class), any(TokenDTO.class)); + } + + @Test + void testDescribe_NullContext_ReturnsBadRequest() { + // Arrange + TooltipDescribeRequest request = TooltipDescribeRequest.builder() + .context(null) + .timestamp(System.currentTimeMillis()) + .build(); + + // Act + ResponseEntity response = tooltipController.describe( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertTrue(response.getBody().getDescription().contains("context is required")); + verify(tooltipService, never()).describeElement(any(), any()); + } + + @Test + void testDescribe_ServiceException_ReturnsInternalServerError() { + // Arrange + TooltipDescribeRequest.ElementContext context = TooltipDescribeRequest.ElementContext.builder() + .tagName("BUTTON") + .id("submit-btn") + .build(); + + TooltipDescribeRequest request = TooltipDescribeRequest.builder() + .context(context) + .build(); + + when(tooltipService.describeElement(any(TooltipDescribeRequest.class), any(TokenDTO.class))) + .thenThrow(new RuntimeException("Service error")); + + // Act + ResponseEntity response = tooltipController.describe( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertNotNull(response.getBody().getError()); + } + + @Test + void testChat_ValidRequest_ReturnsResponse() { + // Arrange + TooltipChatRequest request = TooltipChatRequest.builder() + .message("What does the submit button do?") + .timestamp(System.currentTimeMillis()) + .build(); + + TooltipChatResponse expectedResponse = TooltipChatResponse.builder() + .response("The submit button sends your form data to the server.") + .message("The submit button sends your form data to the server.") + .success(true) + .build(); + + when(tooltipService.chat(any(TooltipChatRequest.class), any(TokenDTO.class))) + .thenReturn(expectedResponse); + + // Act + ResponseEntity response = tooltipController.chat( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().isSuccess()); + assertEquals("The submit button sends your form data to the server.", + response.getBody().getResponse()); + verify(tooltipService).chat(any(TooltipChatRequest.class), any(TokenDTO.class)); + } + + @Test + void testChat_EmptyMessage_ReturnsBadRequest() { + // Arrange + TooltipChatRequest request = TooltipChatRequest.builder() + .message("") + .timestamp(System.currentTimeMillis()) + .build(); + + // Act + ResponseEntity response = tooltipController.chat( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + assertTrue(response.getBody().getResponse().contains("message is required")); + verify(tooltipService, never()).chat(any(), any()); + } + + @Test + void testChat_NullMessage_ReturnsBadRequest() { + // Arrange + TooltipChatRequest request = TooltipChatRequest.builder() + .message(null) + .timestamp(System.currentTimeMillis()) + .build(); + + // Act + ResponseEntity response = tooltipController.chat( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isSuccess()); + verify(tooltipService, never()).chat(any(), any()); + } + + @Test + void testChat_WithElementContext_IncludesContext() { + // Arrange + TooltipDescribeRequest.ElementContext context = TooltipDescribeRequest.ElementContext.builder() + .tagName("INPUT") + .id("username") + .textContent("") + .build(); + + TooltipChatRequest request = TooltipChatRequest.builder() + .message("What should I enter here?") + .context(context) + .timestamp(System.currentTimeMillis()) + .build(); + + TooltipChatResponse expectedResponse = TooltipChatResponse.builder() + .response("Enter your username for authentication.") + .success(true) + .build(); + + when(tooltipService.chat(any(TooltipChatRequest.class), any(TokenDTO.class))) + .thenReturn(expectedResponse); + + // Act + ResponseEntity response = tooltipController.chat( + request, httpRequest, httpResponse); + + // Assert + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().isSuccess()); + verify(tooltipService).chat(any(TooltipChatRequest.class), any(TokenDTO.class)); + } +} diff --git a/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatRequest.java b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatRequest.java new file mode 100644 index 00000000..5963b2e0 --- /dev/null +++ b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatRequest.java @@ -0,0 +1,31 @@ +package io.sentrius.sso.core.dto.tooltip; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for tooltip/chat endpoint. + * Contains the user's chat message and optional element context. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TooltipChatRequest { + /** + * The user's chat message/question + */ + private String message; + + /** + * Optional element context if the chat is related to a specific UI element + */ + private TooltipDescribeRequest.ElementContext context; + + /** + * Optional timestamp for tracking/logging purposes + */ + private Long timestamp; +} diff --git a/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatResponse.java b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatResponse.java new file mode 100644 index 00000000..6fc379f7 --- /dev/null +++ b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipChatResponse.java @@ -0,0 +1,36 @@ +package io.sentrius.sso.core.dto.tooltip; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Response DTO for tooltip/chat endpoint. + * Contains the AI-generated chat response. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TooltipChatResponse { + /** + * The AI-generated chat response + */ + private String response; + + /** + * Optional alternative message field for compatibility + */ + private String message; + + /** + * Indicates if the response was generated successfully + */ + private boolean success; + + /** + * Optional error message if generation failed + */ + private String error; +} diff --git a/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeRequest.java b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeRequest.java new file mode 100644 index 00000000..d740667b --- /dev/null +++ b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeRequest.java @@ -0,0 +1,72 @@ +package io.sentrius.sso.core.dto.tooltip; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Request DTO for tooltip/describe endpoint. + * Contains context information about the UI element that needs a tooltip description. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TooltipDescribeRequest { + /** + * Element context information extracted from the frontend + */ + private ElementContext context; + + /** + * Optional timestamp for tracking/logging purposes + */ + private Long timestamp; + + /** + * Context information about a UI element + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ElementContext { + /** + * HTML tag name (e.g., "BUTTON", "INPUT") + */ + private String tagName; + + /** + * Element ID attribute + */ + private String id; + + /** + * Element class names + */ + private String className; + + /** + * Text content of the element (truncated) + */ + private String textContent; + + /** + * Relevant HTML attributes (type, name, value, etc.) + */ + private Map attributes; + + /** + * Inner HTML (truncated) + */ + private String innerHTML; + + /** + * CSS path to the element + */ + private String path; + } +} diff --git a/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeResponse.java b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeResponse.java new file mode 100644 index 00000000..9d229d16 --- /dev/null +++ b/core/src/main/java/io/sentrius/sso/core/dto/tooltip/TooltipDescribeResponse.java @@ -0,0 +1,36 @@ +package io.sentrius.sso.core.dto.tooltip; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Response DTO for tooltip/describe endpoint. + * Contains the AI-generated description/tooltip for a UI element. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TooltipDescribeResponse { + /** + * The AI-generated description/tooltip text + */ + private String description; + + /** + * Optional detailed message with additional context + */ + private String message; + + /** + * Indicates if the response was generated successfully + */ + private boolean success; + + /** + * Optional error message if generation failed + */ + private String error; +} diff --git a/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/CodebaseIndexingService.java b/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/CodebaseIndexingService.java new file mode 100644 index 00000000..d80e1aba --- /dev/null +++ b/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/CodebaseIndexingService.java @@ -0,0 +1,356 @@ +package io.sentrius.sso.core.services.tooltip; + +import io.sentrius.sso.core.model.documents.Document; +import io.sentrius.sso.core.services.documents.DocumentService; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Service for indexing codebase and documentation files for tooltip context. + * Scans Java source files, markdown documentation, and other relevant files. + */ +@Slf4j +@Service +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class CodebaseIndexingService { + + private final DocumentService documentService; + + @Value("${sentrius.tooltip.index.codebase-path:}") + private String codebasePath; + + @Value("${sentrius.tooltip.index.enabled:false}") + private boolean indexingEnabled; + + public CodebaseIndexingService(DocumentService documentService) { + this.documentService = documentService; + } + + /** + * Index the entire codebase and documentation + */ + public IndexingResult indexCodebase() { + if (!indexingEnabled) { + log.info("Codebase indexing is disabled"); + return IndexingResult.builder() + .success(false) + .message("Indexing is disabled") + .build(); + } + + if (codebasePath == null || codebasePath.isEmpty()) { + log.warn("Codebase path not configured for indexing"); + return IndexingResult.builder() + .success(false) + .message("Codebase path not configured") + .build(); + } + + Path basePath = Paths.get(codebasePath); + if (!Files.exists(basePath) || !Files.isDirectory(basePath)) { + log.error("Codebase path does not exist or is not a directory: {}", codebasePath); + return IndexingResult.builder() + .success(false) + .message("Invalid codebase path") + .build(); + } + + log.info("Starting codebase indexing from: {}", codebasePath); + + int totalFiles = 0; + int successCount = 0; + int errorCount = 0; + List errors = new ArrayList<>(); + + try { + // Index markdown documentation + List mdFiles = findFiles(basePath, "**/*.md"); + log.info("Found {} markdown files to index", mdFiles.size()); + for (Path file : mdFiles) { + totalFiles++; + try { + indexDocumentationFile(file, basePath); + successCount++; + } catch (Exception e) { + errorCount++; + errors.add("Error indexing " + file + ": " + e.getMessage()); + log.warn("Error indexing file: {}", file, e); + } + } + + // Index Java source files (controllers, services with annotations) + List javaFiles = findFiles(basePath, "**/*Controller.java", "**/*Service.java"); + log.info("Found {} Java files to index", javaFiles.size()); + for (Path file : javaFiles) { + totalFiles++; + try { + indexJavaFile(file, basePath); + successCount++; + } catch (Exception e) { + errorCount++; + errors.add("Error indexing " + file + ": " + e.getMessage()); + log.warn("Error indexing file: {}", file, e); + } + } + + log.info("Codebase indexing completed: {} files processed, {} successful, {} errors", + totalFiles, successCount, errorCount); + + return IndexingResult.builder() + .success(true) + .totalFiles(totalFiles) + .successCount(successCount) + .errorCount(errorCount) + .errors(errors) + .message("Indexing completed successfully") + .build(); + + } catch (Exception e) { + log.error("Error during codebase indexing", e); + return IndexingResult.builder() + .success(false) + .message("Indexing failed: " + e.getMessage()) + .build(); + } + } + + /** + * Index a markdown documentation file + */ + private void indexDocumentationFile(Path file, Path basePath) throws IOException { + String content = Files.readString(file); + String relativePath = basePath.relativize(file).toString(); + String fileName = file.getFileName().toString(); + + // Extract title from first heading or filename + String title = extractMarkdownTitle(content, fileName); + + // Extract summary from first paragraph + String summary = extractMarkdownSummary(content); + + // Store document + documentService.storeDocument( + title, + "DOCUMENTATION", + content, + "text/markdown", + summary, + new String[]{"documentation", "markdown", "sentrius"}, + "PUBLIC", + null, + "system" + ); + + log.debug("Indexed documentation file: {}", relativePath); + } + + /** + * Index a Java source file + */ + private void indexJavaFile(Path file, Path basePath) throws IOException { + String content = Files.readString(file); + String relativePath = basePath.relativize(file).toString(); + String fileName = file.getFileName().toString(); + + // Extract class name and package + String className = extractJavaClassName(content, fileName); + String packageName = extractJavaPackage(content); + + // Extract class-level JavaDoc + String summary = extractJavaDocSummary(content); + + // Create title from package and class name + String title = packageName != null ? packageName + "." + className : className; + + // Store document + documentService.storeDocument( + title, + "SOURCE_CODE", + content, + "text/x-java", + summary != null ? summary : "Java source file: " + className, + new String[]{"java", "source", "sentrius", determineFileType(fileName)}, + "PUBLIC", + null, + "system" + ); + + log.debug("Indexed Java file: {}", relativePath); + } + + /** + * Find files matching glob patterns + */ + private List findFiles(Path basePath, String... patterns) throws IOException { + List results = new ArrayList<>(); + + for (String pattern : patterns) { + try (Stream paths = Files.walk(basePath)) { + paths.filter(Files::isRegularFile) + .filter(p -> matchesGlob(basePath.relativize(p).toString(), pattern)) + .forEach(results::add); + } + } + + return results; + } + + /** + * Simple glob pattern matching. + * Converts glob patterns to regex for matching file paths. + * Supports: ** (any path), * (any non-separator chars), ? (single char) + * + * Note: This is a simplified implementation. For production use with complex patterns, + * consider using Java NIO's PathMatcher or Apache Commons IO. + */ + private boolean matchesGlob(String path, String pattern) { + // Convert glob to regex + // ** matches any path including separators + // * matches any characters except path separator + // ? matches single character + String regex = pattern + .replace("**", "DOUBLESTAR") + .replace("*", "[^/]*") + .replace("DOUBLESTAR", ".*") + .replace("?", "."); + return path.matches(regex); + } + + /** + * Extract title from markdown content + */ + private String extractMarkdownTitle(String content, String fileName) { + String[] lines = content.split("\n"); + for (String line : lines) { + if (line.startsWith("# ")) { + return line.substring(2).trim(); + } + } + return fileName.replace(".md", "").replace("-", " ").replace("_", " "); + } + + /** + * Extract summary from markdown content + */ + private String extractMarkdownSummary(String content) { + String[] lines = content.split("\n"); + StringBuilder summary = new StringBuilder(); + boolean foundContent = false; + + for (String line : lines) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + if (foundContent) break; + continue; + } + foundContent = true; + summary.append(line).append(" "); + if (summary.length() > 200) break; + } + + String result = summary.toString().trim(); + return result.isEmpty() ? null : result; + } + + /** + * Extract Java class name + */ + private String extractJavaClassName(String content, String fileName) { + String[] lines = content.split("\n"); + for (String line : lines) { + if (line.contains("class ") || line.contains("interface ") || line.contains("enum ")) { + // Simple extraction + String trimmed = line.trim(); + if (trimmed.startsWith("public ") || trimmed.startsWith("class ") || + trimmed.startsWith("interface ") || trimmed.startsWith("enum ")) { + String[] parts = trimmed.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if (parts[i].equals("class") || parts[i].equals("interface") || parts[i].equals("enum")) { + return parts[i + 1].split("[<{]")[0]; + } + } + } + } + } + return fileName.replace(".java", ""); + } + + /** + * Extract Java package name + */ + private String extractJavaPackage(String content) { + String[] lines = content.split("\n"); + for (String line : lines) { + if (line.trim().startsWith("package ")) { + return line.trim().substring(8).replace(";", "").trim(); + } + } + return null; + } + + /** + * Extract JavaDoc summary + */ + private String extractJavaDocSummary(String content) { + int start = content.indexOf("/**"); + if (start == -1) return null; + + int end = content.indexOf("*/", start); + if (end == -1) return null; + + String javadoc = content.substring(start + 3, end); + String[] lines = javadoc.split("\n"); + StringBuilder summary = new StringBuilder(); + + for (String line : lines) { + line = line.trim(); + if (line.startsWith("*")) line = line.substring(1).trim(); + if (line.isEmpty() || line.startsWith("@")) break; + summary.append(line).append(" "); + } + + String result = summary.toString().trim(); + return result.isEmpty() ? null : result; + } + + /** + * Determine file type from filename + */ + private String determineFileType(String fileName) { + if (fileName.contains("Controller")) return "controller"; + if (fileName.contains("Service")) return "service"; + if (fileName.contains("Repository")) return "repository"; + if (fileName.contains("DTO")) return "dto"; + return "other"; + } + + /** + * Result of indexing operation + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class IndexingResult { + private boolean success; + private String message; + private int totalFiles; + private int successCount; + private int errorCount; + private List errors; + } +} diff --git a/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/TooltipService.java b/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/TooltipService.java new file mode 100644 index 00000000..ca1854c3 --- /dev/null +++ b/dataplane/src/main/java/io/sentrius/sso/core/services/tooltip/TooltipService.java @@ -0,0 +1,346 @@ +package io.sentrius.sso.core.services.tooltip; + +import com.fasterxml.jackson.core.JsonProcessingException; +import io.sentrius.sso.core.dto.documents.DocumentSearchDTO; +import io.sentrius.sso.core.dto.tooltip.TooltipChatRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipChatResponse; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeRequest; +import io.sentrius.sso.core.dto.tooltip.TooltipDescribeResponse; +import io.sentrius.sso.core.dto.ztat.TokenDTO; +import io.sentrius.sso.core.exceptions.ZtatException; +import io.sentrius.sso.core.model.documents.Document; +import io.sentrius.sso.core.services.agents.LLMService; +import io.sentrius.sso.core.services.documents.DocumentService; +import io.sentrius.sso.core.utils.JsonUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Service for providing AI-powered tooltips and help for UI elements. + * Searches indexed codebase and documentation to provide contextual information. + */ +@Slf4j +@Service +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +public class TooltipService { + + private final DocumentService documentService; + private final LLMService llmService; + + @Value("${sentrius.tooltip.max-context-documents:5}") + private int maxContextDocuments; + + @Value("${sentrius.tooltip.similarity-threshold:0.5}") + private double similarityThreshold; + + @Value("${sentrius.tooltip.llm-model:gpt-4o-mini}") + private String llmModel; + + public TooltipService(DocumentService documentService, LLMService llmService) { + this.documentService = documentService; + this.llmService = llmService; + } + + /** + * Generate AI-powered description for a UI element + */ + public TooltipDescribeResponse describeElement(TooltipDescribeRequest request, TokenDTO tokenDTO) { + try { + log.info("Generating tooltip description for element: {}", + request.getContext() != null ? request.getContext().getId() : "unknown"); + + // Build search query from element context + String searchQuery = buildSearchQuery(request.getContext()); + log.debug("Search query: {}", searchQuery); + + // Search for relevant documentation + List relevantDocs = searchRelevantDocumentation(searchQuery); + log.debug("Found {} relevant documents", relevantDocs.size()); + + // Build context for LLM + String context = buildLLMContext(relevantDocs, request.getContext()); + + // Generate description using LLM + String description = generateDescription(context, request.getContext(), tokenDTO); + + return TooltipDescribeResponse.builder() + .description(description) + .message(description) + .success(true) + .build(); + + } catch (ZtatException e) { + log.error("ZTAT exception generating tooltip description", e); + return TooltipDescribeResponse.builder() + .description("Unable to authenticate with LLM service.") + .error(e.getMessage()) + .success(false) + .build(); + } catch (Exception e) { + log.error("Error generating tooltip description", e); + return TooltipDescribeResponse.builder() + .description("Unable to generate description at this time.") + .error(e.getMessage()) + .success(false) + .build(); + } + } + + /** + * Handle chat conversation for contextual help + */ + public TooltipChatResponse chat(TooltipChatRequest request, TokenDTO tokenDTO) { + try { + log.info("Processing chat request: {}", request.getMessage()); + + // Search for relevant documentation based on the message + String searchQuery = request.getMessage(); + if (request.getContext() != null) { + searchQuery = request.getMessage() + " " + buildSearchQuery(request.getContext()); + } + + List relevantDocs = searchRelevantDocumentation(searchQuery); + log.debug("Found {} relevant documents for chat", relevantDocs.size()); + + // Build context for LLM + String context = buildLLMContext(relevantDocs, request.getContext()); + + // Generate chat response using LLM + String response = generateChatResponse(context, request.getMessage(), request.getContext(), tokenDTO); + + return TooltipChatResponse.builder() + .response(response) + .message(response) + .success(true) + .build(); + + } catch (ZtatException e) { + log.error("ZTAT exception generating chat response", e); + return TooltipChatResponse.builder() + .response("Unable to authenticate with LLM service.") + .error(e.getMessage()) + .success(false) + .build(); + } catch (Exception e) { + log.error("Error generating chat response", e); + return TooltipChatResponse.builder() + .response("Unable to generate response at this time.") + .error(e.getMessage()) + .success(false) + .build(); + } + } + + /** + * Build a search query from element context + */ + private String buildSearchQuery(TooltipDescribeRequest.ElementContext context) { + if (context == null) { + return ""; + } + + StringBuilder query = new StringBuilder(); + + // Add ID if present + if (context.getId() != null && !context.getId().trim().isEmpty()) { + query.append(context.getId()).append(" "); + } + + // Add class names + if (context.getClassName() != null && !context.getClassName().trim().isEmpty()) { + String[] classes = context.getClassName().split("\\s+"); + for (String cls : classes) { + if (cls.length() > 2) { // Skip very short class names + query.append(cls).append(" "); + } + } + } + + // Add text content (first few words) + if (context.getTextContent() != null && !context.getTextContent().trim().isEmpty()) { + String text = context.getTextContent().trim(); + String[] words = text.split("\\s+"); + int wordCount = Math.min(words.length, 10); + for (int i = 0; i < wordCount; i++) { + query.append(words[i]).append(" "); + } + } + + // Add relevant attributes + if (context.getAttributes() != null) { + Map attrs = context.getAttributes(); + if (attrs.containsKey("name")) { + query.append(attrs.get("name")).append(" "); + } + if (attrs.containsKey("title")) { + query.append(attrs.get("title")).append(" "); + } + if (attrs.containsKey("aria-label")) { + query.append(attrs.get("aria-label")).append(" "); + } + } + + return query.toString().trim(); + } + + /** + * Search for relevant documentation + * + * Note: Using deprecated searchDocuments method as we need internal search without access control. + * The newer method requires AccessEvaluator which we don't have in this service context. + * This is safe as the tooltip service is already behind authentication/authorization. + * TODO: Consider refactoring DocumentService to provide an internal search method. + */ + private List searchRelevantDocumentation(String query) { + if (query == null || query.trim().isEmpty()) { + return new ArrayList<>(); + } + + try { + DocumentSearchDTO searchDTO = DocumentSearchDTO.builder() + .query(query) + .useSemanticSearch(true) + .threshold(similarityThreshold) + .limit(maxContextDocuments) + .build(); + + // Using deprecated method for internal search - see method documentation above + @SuppressWarnings("deprecation") + List results = documentService.searchDocuments(searchDTO); + return results; + + } catch (Exception e) { + log.warn("Error searching documentation: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Build context string for LLM from relevant documents + */ + private String buildLLMContext(List documents, TooltipDescribeRequest.ElementContext elementContext) { + StringBuilder context = new StringBuilder(); + + context.append("You are a helpful assistant for the Sentrius Zero Trust Security Platform. "); + context.append("Provide clear, concise explanations about UI elements and features.\n\n"); + + if (!documents.isEmpty()) { + context.append("Relevant documentation:\n"); + for (Document doc : documents) { + context.append("- ").append(doc.getDocumentName()).append(": "); + String summary = doc.getSummary(); + if (summary != null && !summary.isEmpty()) { + context.append(summary); + } else { + // Use first 200 characters of content + String content = doc.getContent(); + if (content != null) { + int endIndex = Math.min(200, content.length()); + context.append(content.substring(0, endIndex)); + if (content.length() > 200) { + context.append("..."); + } + } + } + context.append("\n"); + } + context.append("\n"); + } + + if (elementContext != null) { + context.append("Element information:\n"); + if (elementContext.getTagName() != null) { + context.append("- Tag: ").append(elementContext.getTagName()).append("\n"); + } + if (elementContext.getId() != null && !elementContext.getId().isEmpty()) { + context.append("- ID: ").append(elementContext.getId()).append("\n"); + } + if (elementContext.getTextContent() != null && !elementContext.getTextContent().isEmpty()) { + context.append("- Text: ").append(elementContext.getTextContent()).append("\n"); + } + if (elementContext.getAttributes() != null && !elementContext.getAttributes().isEmpty()) { + context.append("- Attributes: ").append(elementContext.getAttributes()).append("\n"); + } + } + + return context.toString(); + } + + /** + * Generate description using LLM + */ + private String generateDescription(String context, TooltipDescribeRequest.ElementContext elementContext, + TokenDTO tokenDTO) throws ZtatException, JsonProcessingException { + String userPrompt = "Provide a brief, helpful description (2-3 sentences) of what this UI element does. "; + userPrompt += "Focus on its purpose and how users should interact with it."; + + return callLLM(context, userPrompt, tokenDTO); + } + + /** + * Generate chat response using LLM + */ + private String generateChatResponse(String context, String userMessage, + TooltipDescribeRequest.ElementContext elementContext, + TokenDTO tokenDTO) throws ZtatException, JsonProcessingException { + String userPrompt = "User question: " + userMessage + "\n\n"; + userPrompt += "Please provide a helpful answer based on the documentation provided. "; + userPrompt += "Be specific and reference relevant features when appropriate."; + + return callLLM(context, userPrompt, tokenDTO); + } + + /** + * Call LLM service with the given context and prompt + */ + private String callLLM(String systemContext, String userPrompt, TokenDTO tokenDTO) + throws ZtatException, JsonProcessingException { + + List> messages = new ArrayList<>(); + + // System message with context + Map systemMessage = new HashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", systemContext); + messages.add(systemMessage); + + // User message + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", userPrompt); + messages.add(userMessage); + + // Build request + Map request = new HashMap<>(); + request.put("model", llmModel); + request.put("messages", messages); + request.put("max_tokens", 300); + request.put("temperature", 0.7); + + // Call LLM + String responseJson = llmService.askQuestion(tokenDTO, request); + + // Parse response + var response = JsonUtil.MAPPER.readTree(responseJson); + var choices = response.get("choices"); + if (choices != null && choices.isArray() && !choices.isEmpty()) { + var firstChoice = choices.get(0); + var message = firstChoice.get("message"); + if (message != null) { + var content = message.get("content"); + if (content != null) { + return content.asText(); + } + } + } + + throw new RuntimeException("Unable to parse LLM response"); + } +} diff --git a/docs/TOOLTIP_SYSTEM.md b/docs/TOOLTIP_SYSTEM.md new file mode 100644 index 00000000..045be58a --- /dev/null +++ b/docs/TOOLTIP_SYSTEM.md @@ -0,0 +1,249 @@ +# AI-Powered Tooltip System + +## Overview + +The Sentrius AI-Powered Tooltip System provides contextual help and descriptions for UI elements using LLM integration and indexed codebase documentation. Users can right-click on UI elements to get intelligent, context-aware tooltips and have conversational assistance about features. + +## Architecture + +### Components + +1. **Frontend Integration** (`ai-helper.js`) + - Right-click context menu for element descriptions + - Chat modal for conversational assistance + - Automatic element context extraction (tag, ID, classes, attributes, text content) + +2. **Backend API** (`TooltipController`) + - `/api/v1/tooltip/describe` - Get AI description for a UI element + - `/api/v1/tooltip/chat` - Chat with AI about features and elements + - `/api/v1/tooltip/admin/index` - Trigger manual codebase indexing (admin only) + +3. **Services** + - **TooltipService** - Orchestrates tooltip generation using document search and LLM + - **CodebaseIndexingService** - Indexes Java source files and markdown documentation + - **DocumentService** - Provides semantic search over indexed content + - **LLMService** - Integrates with OpenAI/LLM providers for text generation + +4. **Data Transfer Objects** + - `TooltipDescribeRequest` / `TooltipDescribeResponse` + - `TooltipChatRequest` / `TooltipChatResponse` + +## How It Works + +### Description Flow + +1. User right-clicks on a UI element +2. Frontend extracts element context (tag, ID, classes, text, attributes, DOM path) +3. Context is sent to `/api/v1/tooltip/describe` endpoint +4. Backend: + - Builds search query from element context + - Searches indexed documentation using semantic search + - Combines relevant docs with element info + - Calls LLM to generate concise description (2-3 sentences) +5. Description is displayed in a notification popup + +### Chat Flow + +1. User opens AI chat modal (via right-click menu or directly) +2. User types a question about a feature or element +3. Message is sent to `/api/v1/tooltip/chat` endpoint +4. Backend: + - Searches documentation based on the question + - Optionally includes element context if provided + - Calls LLM to generate helpful response +5. Response is displayed in chat conversation + +### Indexing Flow + +1. **Automatic** (on application startup if configured): + - Scans codebase directory for `.md` and `*Controller.java` / `*Service.java` files + - Extracts metadata (title, summary, package, class name, JavaDoc) + - Generates embeddings for semantic search + - Stores in Document repository + +2. **Manual** (via admin endpoint): + - Admin users can trigger `/api/v1/tooltip/admin/index` + - Re-indexes codebase on demand + - Returns statistics (files processed, success/error counts) + +## Configuration + +### Application Properties + +Add to `application.properties` or `application.yml`: + +```properties +# Tooltip Configuration +sentrius.tooltip.max-context-documents=5 +sentrius.tooltip.similarity-threshold=0.5 +sentrius.tooltip.llm-model=gpt-4o-mini + +# Indexing Configuration +sentrius.tooltip.index.enabled=true +sentrius.tooltip.index.codebase-path=/path/to/sentrius/codebase + +# LLM Endpoint (if not using default) +agent.open.ai.endpoint=http://localhost:8080 +``` + +### Frontend Integration + +Include the AI Helper library in your HTML templates: + +```html + + + +``` + +## API Endpoints + +### POST /api/v1/tooltip/describe + +Get AI-powered description for a UI element. + +**Request:** +```json +{ + "context": { + "tagName": "BUTTON", + "id": "submit-btn", + "className": "btn btn-primary", + "textContent": "Submit Form", + "attributes": { + "type": "button", + "aria-label": "Submit the form" + }, + "path": "body > div.container > form > button#submit-btn" + }, + "timestamp": 1234567890 +} +``` + +**Response:** +```json +{ + "description": "This button submits the form data to the server. Click it after filling in all required fields to save your changes.", + "message": "This button submits the form data to the server. Click it after filling in all required fields to save your changes.", + "success": true +} +``` + +### POST /api/v1/tooltip/chat + +Chat with AI assistant about features and elements. + +**Request:** +```json +{ + "message": "How do I configure SSH access policies?", + "context": null, + "timestamp": 1234567890 +} +``` + +**Response:** +```json +{ + "response": "To configure SSH access policies in Sentrius, navigate to the Access Policies page. You can define rules based on user attributes, time windows, and resource tags. Policies use ABAC (Attribute-Based Access Control) to enforce zero-trust security.", + "message": "To configure SSH access policies in Sentrius...", + "success": true +} +``` + +### POST /api/v1/tooltip/admin/index + +Trigger manual indexing of codebase and documentation (admin only). + +**Response:** +```json +{ + "success": true, + "message": "Indexing completed successfully", + "totalFiles": 150, + "successCount": 148, + "errorCount": 2, + "errors": [ + "Error indexing docs/broken.md: File not found", + "Error indexing src/BrokenController.java: Parse error" + ] +} +``` + +## Security + +- All endpoints require user authentication (CAN_LOG_IN permission) +- Admin indexing endpoint requires CAN_MANAGE_SYSTEMS permission +- CSRF protection via X-CSRF-TOKEN header +- User context is preserved for audit logging + +## Performance Considerations + +- **Semantic Search**: Uses vector embeddings for fast similarity search +- **Result Limiting**: Configurable `max-context-documents` (default: 5) +- **Similarity Threshold**: Configurable minimum similarity score (default: 0.5) +- **LLM Token Limits**: Descriptions limited to 300 tokens, context limited to first 200 chars per document + +## Troubleshooting + +### No tooltips appearing +- Check that indexing has been run (`/api/v1/tooltip/admin/index`) +- Verify `sentrius.tooltip.index.enabled=true` +- Ensure LLM service is available and configured + +### Poor quality tooltips +- Increase `sentrius.tooltip.max-context-documents` for more context +- Lower `sentrius.tooltip.similarity-threshold` for more results +- Re-index with updated documentation + +### Indexing errors +- Verify `sentrius.tooltip.index.codebase-path` is correct and accessible +- Check file permissions for reading source files +- Review error messages in response for specific file issues + +## Future Enhancements + +- [ ] Real-time indexing on file changes +- [ ] Support for additional file types (XML, YAML, properties) +- [ ] User-specific tooltip customization +- [ ] Tooltip analytics and feedback +- [ ] Caching layer for frequently requested tooltips +- [ ] Multi-language support + +## Development + +### Adding New Content Types + +To index additional content types, extend `CodebaseIndexingService`: + +1. Add a new `index[Type]File` method +2. Update `indexCodebase()` to call the new method +3. Add glob patterns to `findFiles()` call + +### Customizing LLM Prompts + +Edit the prompt templates in `TooltipService`: +- `generateDescription()` - For element descriptions +- `generateChatResponse()` - For chat interactions + +### Testing + +Run tests: +```bash +mvn test -pl api -Dtest=TooltipControllerTest +``` + +All tooltip functionality is unit tested with mocked dependencies. + +## License + +Copyright © 2024 Sentrius LLC. All rights reserved. From deb8e239d3f846bd460cb17fde742fe143fa155a Mon Sep 17 00:00:00 2001 From: Marc Parisi Date: Sun, 4 Jan 2026 18:31:48 -0500 Subject: [PATCH 3/6] 2x2 --- .../main/resources/static/css/ai-helper.css | 322 +++++++++++++ api/src/main/resources/static/js/ai-helper.js | 449 ++++++++++++++++++ .../resources/templates/fragments/header.html | 5 +- 3 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 api/src/main/resources/static/css/ai-helper.css create mode 100644 api/src/main/resources/static/js/ai-helper.js diff --git a/api/src/main/resources/static/css/ai-helper.css b/api/src/main/resources/static/css/ai-helper.css new file mode 100644 index 00000000..ea422c86 --- /dev/null +++ b/api/src/main/resources/static/css/ai-helper.css @@ -0,0 +1,322 @@ +/** + * AI Helper Styles + */ + +/* Context Menu */ +.ai-helper-context-menu { + position: absolute; + z-index: 10000; + background: #2b2b2b; + border: 1px solid #3f3f3f; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + padding: 4px 0; + min-width: 200px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.ai-helper-menu-item { + padding: 10px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #e0e0e0; + transition: background-color 0.2s; +} + +.ai-helper-menu-item:hover { + background-color: #3a3a3a; +} + +.ai-helper-icon { + font-size: 16px; +} + +/* Chat Modal */ +.ai-helper-chat-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.ai-helper-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); +} + +.ai-helper-modal-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90%; + max-width: 600px; + height: 70vh; + max-height: 600px; + background: #1e1e1e; + border: 1px solid #3f3f3f; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ai-helper-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #3f3f3f; + background: #252525; +} + +.ai-helper-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #e0e0e0; +} + +.ai-helper-close-btn { + background: none; + border: none; + font-size: 28px; + color: #b0b0b0; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.ai-helper-close-btn:hover { + background-color: #3a3a3a; +} + +.ai-helper-chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + background: #1e1e1e; +} + +.ai-helper-chat-message { + display: flex; + max-width: 85%; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.ai-helper-message-content { + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; +} + +.ai-helper-chat-message-user { + align-self: flex-end; +} + +.ai-helper-chat-message-user .ai-helper-message-content { + background: #007bff; + color: white; + border-bottom-right-radius: 4px; +} + +.ai-helper-chat-message-assistant, +.ai-helper-chat-message-system { + align-self: flex-start; +} + +.ai-helper-chat-message-assistant .ai-helper-message-content { + background: #2b2b2b; + color: #e0e0e0; + border-bottom-left-radius: 4px; +} + +.ai-helper-chat-message-system .ai-helper-message-content { + background: #1a3a52; + color: #64b5f6; + border-bottom-left-radius: 4px; + font-size: 13px; +} + +.ai-helper-chat-message-error .ai-helper-message-content { + background: #4a2828; + color: #ef5350; + border-bottom-left-radius: 4px; +} + +.ai-helper-chat-input-container { + padding: 16px 20px; + border-top: 1px solid #3f3f3f; + display: flex; + gap: 12px; + background: #252525; +} + +.ai-helper-chat-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #3f3f3f; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + resize: none; + outline: none; + transition: border-color 0.2s; + background: #2b2b2b; + color: #e0e0e0; +} + +.ai-helper-chat-input:focus { + border-color: #007bff; +} + +.ai-helper-send-btn { + padding: 10px 20px; + background: #007bff; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + align-self: flex-end; +} + +.ai-helper-send-btn:hover { + background: #0056b3; +} + +.ai-helper-send-btn:active { + transform: scale(0.98); +} + +/* Notification */ +.ai-helper-notification { + position: fixed; + top: 20px; + right: 20px; + z-index: 10001; + max-width: 400px; + animation: slideInRight 0.3s ease-out; +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.ai-helper-notification-content { + background: #2b2b2b; + border: 1px solid #3f3f3f; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + padding: 16px; +} + +.ai-helper-notification-content strong { + display: block; + margin-bottom: 8px; + color: #e0e0e0; + font-size: 14px; +} + +.ai-helper-notification-content p { + margin: 0 0 12px 0; + color: #b0b0b0; + font-size: 13px; + line-height: 1.5; +} + +.ai-helper-notification-close { + padding: 6px 12px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; +} + +.ai-helper-notification-close:hover { + background: #0056b3; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .ai-helper-modal-content { + width: 95%; + height: 80vh; + max-height: none; + } + + .ai-helper-chat-message { + max-width: 90%; + } + + .ai-helper-notification { + top: 10px; + right: 10px; + left: 10px; + max-width: none; + } +} + +/* Scrollbar Styling */ +.ai-helper-chat-messages::-webkit-scrollbar { + width: 8px; +} + +.ai-helper-chat-messages::-webkit-scrollbar-track { + background: #252525; +} + +.ai-helper-chat-messages::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; +} + +.ai-helper-chat-messages::-webkit-scrollbar-thumb:hover { + background: #777; +} diff --git a/api/src/main/resources/static/js/ai-helper.js b/api/src/main/resources/static/js/ai-helper.js new file mode 100644 index 00000000..4dfaf23c --- /dev/null +++ b/api/src/main/resources/static/js/ai-helper.js @@ -0,0 +1,449 @@ +/** + * AI Helper Library + * Enables right-click context menu to get AI-powered descriptions and chat assistance + */ + + +(function(window, document) { + 'use strict'; + + // Default configuration constants + const DEFAULT_MAX_TEXT_LENGTH = 500; + const DEFAULT_MAX_HTML_LENGTH = 1000; + const NOTIFICATION_AUTO_CLOSE_MS = 10000; + + class AIHelper { + constructor(options = {}) { + this.options = { + apiEndpoint: options.apiEndpoint || '/api/v1/tooltip', + enableDescriptions: options.enableDescriptions !== false, + enableChat: options.enableChat !== false, + contextMenuId: 'ai-helper-context-menu', + chatModalId: 'ai-helper-chat-modal', + maxTextContentLength: options.maxTextContentLength || DEFAULT_MAX_TEXT_LENGTH, + maxInnerHTMLLength: options.maxInnerHTMLLength || DEFAULT_MAX_HTML_LENGTH, + notificationAutoCloseMs: options.notificationAutoCloseMs || NOTIFICATION_AUTO_CLOSE_MS, + ...options + }; + + this.selectedElement = null; + this.contextMenu = null; + this.chatModal = null; + this.eventHandlers = { + contextMenu: null, + click: null, + keydown: null + }; + this.init(); + } + + init() { + this.createContextMenu(); + this.createChatModal(); + this.attachEventListeners(); + } + + createContextMenu() { + if (document.getElementById(this.options.contextMenuId)) return; + + const menu = document.createElement('div'); + menu.id = this.options.contextMenuId; + menu.className = 'ai-helper-context-menu'; + menu.style.display = 'none'; + + const menuItems = []; + + if (this.options.enableDescriptions) { + menuItems.push({ + text: 'Get AI Description', + icon: '🤖', + action: () => this.getElementDescription() + }); + } + + if (this.options.enableChat) { + menuItems.push({ + text: 'Open AI Chat', + icon: '💬', + action: () => this.openChatModal() + }); + } + + menuItems.forEach(item => { + const menuItem = document.createElement('div'); + menuItem.className = 'ai-helper-menu-item'; + menuItem.innerHTML = `${item.icon} ${item.text}`; + menuItem.addEventListener('click', (e) => { + e.stopPropagation(); + item.action(); + this.hideContextMenu(); + }); + menu.appendChild(menuItem); + }); + + document.body.appendChild(menu); + this.contextMenu = menu; + } + + createChatModal() { + if (document.getElementById(this.options.chatModalId)) return; + + const modal = document.createElement('div'); + modal.id = this.options.chatModalId; + modal.className = 'ai-helper-chat-modal'; + modal.style.display = 'none'; + + modal.innerHTML = ` +
+
+
+

AI Assistant

+ +
+
+
+ + +
+
+ `; + + document.body.appendChild(modal); + this.chatModal = modal; + + // Attach modal-specific event listeners + const closeBtn = modal.querySelector('.ai-helper-close-btn'); + const overlay = modal.querySelector('.ai-helper-modal-overlay'); + const sendBtn = modal.querySelector('#ai-helper-send-btn'); + const input = modal.querySelector('#ai-helper-chat-input'); + + closeBtn.addEventListener('click', () => this.closeChatModal()); + overlay.addEventListener('click', () => this.closeChatModal()); + sendBtn.addEventListener('click', () => this.sendChatMessage()); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendChatMessage(); + } + }); + } + + attachEventListeners() { + // Right-click event listener + this.eventHandlers.contextMenu = (e) => { + // Allow default context menu if AI Helper menu is disabled + if (!this.options.enableDescriptions && !this.options.enableChat) { + return; + } + + // Check if we should show our menu (not on our own UI elements) + if (e.target.closest('.ai-helper-context-menu') || + e.target.closest('.ai-helper-chat-modal')) { + return; + } + + e.preventDefault(); + this.selectedElement = e.target; + this.showContextMenu(e.pageX, e.pageY); + }; + + // Click anywhere to hide context menu + this.eventHandlers.click = () => { + this.hideContextMenu(); + }; + + // Escape key to close modal + this.eventHandlers.keydown = (e) => { + if (e.key === 'Escape') { + this.closeChatModal(); + } + }; + + document.addEventListener('contextmenu', this.eventHandlers.contextMenu); + document.addEventListener('click', this.eventHandlers.click); + document.addEventListener('keydown', this.eventHandlers.keydown); + } + + showContextMenu(x, y) { + if (!this.contextMenu) return; + + this.contextMenu.style.display = 'block'; + this.contextMenu.style.left = x + 'px'; + this.contextMenu.style.top = y + 'px'; + + // Adjust position if menu goes off-screen + const rect = this.contextMenu.getBoundingClientRect(); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + if (rect.right > windowWidth) { + this.contextMenu.style.left = (x - rect.width) + 'px'; + } + + if (rect.bottom > windowHeight) { + this.contextMenu.style.top = (y - rect.height) + 'px'; + } + } + + hideContextMenu() { + if (this.contextMenu) { + this.contextMenu.style.display = 'none'; + } + } + + async getElementDescription() { + if (!this.selectedElement) return; + + const context = this.extractElementContext(this.selectedElement); + + const token = document.querySelector('meta[name="_csrf"]')?.content; + + try { + const response = await fetch(`${this.options.apiEndpoint}/describe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': token + }, + body: JSON.stringify({ + context: context, + timestamp: Date.now() + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + this.displayDescription(data.description || data.message); + } catch (error) { + console.error('Error fetching description:', error); + this.displayDescription('Error: Unable to get AI description. ' + error.message); + } + } + + extractElementContext(element) { + const context = { + tagName: element.tagName, + id: element.id, + className: element.className, + textContent: element.textContent?.substring(0, this.options.maxTextContentLength), // Limit text length + attributes: {}, + innerHTML: element.innerHTML?.substring(0, this.options.maxInnerHTMLLength), // Limit HTML length + path: this.getElementPath(element) + }; + + // Get relevant attributes + const relevantAttrs = ['type', 'name', 'value', 'placeholder', 'href', 'src', 'alt', 'title', 'aria-label']; + relevantAttrs.forEach(attr => { + if (element.hasAttribute(attr)) { + context.attributes[attr] = element.getAttribute(attr); + } + }); + + return context; + } + + getElementPath(element) { + const path = []; + let current = element; + + while (current && current !== document.body) { + let selector = current.tagName.toLowerCase(); + if (current.id) { + selector += '#' + current.id; + } else if (current.className) { + selector += '.' + current.className.split(' ').filter(c => c).join('.'); + } + path.unshift(selector); + current = current.parentElement; + } + + return path.join(' > '); + } + + displayDescription(description) { + // Create a temporary notification + const notification = document.createElement('div'); + notification.className = 'ai-helper-notification'; + notification.innerHTML = ` +
+ AI Description: +

${this.escapeHtml(description)}

+ +
+ `; + + document.body.appendChild(notification); + + const closeBtn = notification.querySelector('.ai-helper-notification-close'); + closeBtn.addEventListener('click', () => { + notification.remove(); + }); + + // Auto-remove after configured timeout + setTimeout(() => { + if (notification.parentElement) { + notification.remove(); + } + }, this.options.notificationAutoCloseMs); + } + + openChatModal() { + if (!this.chatModal) return; + + this.chatModal.style.display = 'block'; + + // Add initial context message if an element was selected + if (this.selectedElement) { + const context = this.extractElementContext(this.selectedElement); + // Escape potentially dangerous values + const tagName = this.escapeHtml(context.tagName); + const id = context.id ? `(#${this.escapeHtml(context.id)})` : ''; + this.addChatMessage( + `I can help you understand this element: ${tagName} ${id}`, + 'system' + ); + } + + // Focus on input + const input = this.chatModal.querySelector('#ai-helper-chat-input'); + if (input) input.focus(); + } + + closeChatModal() { + if (!this.chatModal) return; + this.chatModal.style.display = 'none'; + } + + addChatMessage(message, type = 'user') { + const messagesContainer = document.getElementById('ai-helper-chat-messages'); + if (!messagesContainer) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `ai-helper-chat-message ai-helper-chat-message-${type}`; + + const messageContent = document.createElement('div'); + messageContent.className = 'ai-helper-message-content'; + messageContent.innerHTML = type === 'system' ? message : this.escapeHtml(message); + + messageDiv.appendChild(messageContent); + messagesContainer.appendChild(messageDiv); + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + async sendChatMessage() { + const input = document.getElementById('ai-helper-chat-input'); + if (!input) return; + + const message = input.value.trim(); + if (!message) return; + + // Display user message + this.addChatMessage(message, 'user'); + input.value = ''; + + // Show loading indicator + const loadingId = 'loading-' + Date.now(); + this.addChatMessage('Thinking...', 'assistant'); + const messagesContainer = document.getElementById('ai-helper-chat-messages'); + const loadingMessage = messagesContainer.lastChild; + loadingMessage.id = loadingId; + + try { + const context = this.selectedElement ? this.extractElementContext(this.selectedElement) : null; + + const response = await fetch(`${this.options.apiEndpoint}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: message, + context: context, + timestamp: Date.now() + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Remove loading message + if (loadingMessage && loadingMessage.parentElement) { + loadingMessage.remove(); + } + + // Display AI response + this.addChatMessage(data.response || data.message, 'assistant'); + } catch (error) { + console.error('Error sending chat message:', error); + + // Remove loading message + if (loadingMessage && loadingMessage.parentElement) { + loadingMessage.remove(); + } + + this.addChatMessage('Error: Unable to get response. ' + error.message, 'error'); + } + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + destroy() { + // Remove all created elements + if (this.contextMenu && this.contextMenu.parentElement) { + this.contextMenu.remove(); + } + if (this.chatModal && this.chatModal.parentElement) { + this.chatModal.remove(); + } + + // Remove event listeners + if (this.eventHandlers.contextMenu) { + document.removeEventListener('contextmenu', this.eventHandlers.contextMenu); + } + if (this.eventHandlers.click) { + document.removeEventListener('click', this.eventHandlers.click); + } + if (this.eventHandlers.keydown) { + document.removeEventListener('keydown', this.eventHandlers.keydown); + } + + // Clear references + this.contextMenu = null; + this.chatModal = null; + this.selectedElement = null; + this.eventHandlers = {}; + } + } + + // Expose to global scope + window.AIHelper = AIHelper; + + // Auto-initialize if data-ai-helper-auto attribute is present + if (document.currentScript && document.currentScript.hasAttribute('data-ai-helper-auto')) { + const apiEndpoint = document.currentScript.getAttribute('data-ai-helper-endpoint') || '/api/ai-helper'; + window.addEventListener('DOMContentLoaded', () => { + window.aiHelperInstance = new AIHelper({ apiEndpoint }); + }); + } + +})(window, document); diff --git a/api/src/main/resources/templates/fragments/header.html b/api/src/main/resources/templates/fragments/header.html index 0f1bdca6..a18898b7 100644 --- a/api/src/main/resources/templates/fragments/header.html +++ b/api/src/main/resources/templates/fragments/header.html @@ -1,7 +1,8 @@ - + + @@ -16,6 +17,7 @@ + @@ -59,6 +61,7 @@ +