1
0

first commit

This commit is contained in:
Nilton Constantino 2026-04-22 14:55:58 +01:00
commit d48172539a
No known key found for this signature in database
149 changed files with 4604 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.gradle
build
buildSrc/build
.idea
*.iml
.git
.claude
audit.ndjson
plugins/

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Gradle
.gradle/
build/
buildSrc/build/
# IDE
.idea/
*.iml
.vscode/
.settings/
.project
.classpath
# OS
.DS_Store
Thumbs.db
# Runtime
audit.ndjson
*.log
# Discussion (per-user)
.claude/discussion/

99
ARCHITECTURAL-FACTS.md Normal file
View File

@ -0,0 +1,99 @@
# Architectural Facts
> Companion document to README.md — covers the *why* behind the architecture, not the *how*.
## Core Principle: AI as Requesting Client
The gateway treats AI agents the same way secure backends treat any client: you cannot stop someone from sending a `curl` request, but you can control whether it **succeeds**. Credentials are not exposed, access is scoped, and every action must pass through policy-enforced backend services.
**The objective is not to "control the AI". The objective is: no meaningful action succeeds unless it passes through controlled, policy-enforced backend systems.**
This aligns with OWASP guidance for AI agents (least privilege, separation of untrusted input from trusted instructions) and MCP security expectations.
## Module Architecture
The project is a **modular monolith** — 17 Gradle modules organized by bounded context, deployed as a single Spring Boot application.
```
app-monolith # Composition root
main-infrastructure # Shared kernel (BaseModel, exceptions, value objects)
main-service # Application services
main-contract-subjects # Cross-module contracts
adapter-http-security-infra # JWT auth, Spring Security config
adapter-http-gateway # HTTP orchestration layer
gateway-module-policy/ # Policy engine (api, shared, contract-subjects, domain, model-and-data)
gateway-module-authorization/ # Authorization (api, shared, contract-subjects, domain, model-and-data)
gateway-module-audit/ # Audit trail (api, contract-subjects, domain)
gateway-module-fraud-detection/ # Fraud detection (api, domain)
gateway-module-tool-registry/ # Tool registry (api, contract-subjects, domain)
gateway-providers/ # Tool implementations (jira, jenkins, database)
```
Each module follows a consistent sub-module split: `api` (interfaces), `domain` (logic), `model-and-data` (persistence/config), `contract-subjects` (cross-module DTOs), `shared` (module-internal utilities).
## Policy Engine: Deny-Overrides Strategy
All policy evaluation uses **deny-overrides**: any single rule can block an action, regardless of other rules that might allow it. The default decision is `deny`.
Six typed rule evaluators handle distinct concerns:
| Rule Type | Purpose |
|-----------|---------|
| `scope` | Verifies the agent holds the required scope for the tool+action |
| `environment` | Per-environment decisions (allow staging, require approval for prod) |
| `argument_allowlist` | Validates specific arguments against an allowlist |
| `time_window` | Restricts execution to business hours or defined windows |
| `rate_limit` | Caps requests per agent per tool within a time window |
| `block` | Unconditional deny for decommissioned or dangerous tools |
**Design choice:** Typed evaluators over a generic DSL (like OPA/Rego). Each rule type is self-documenting and domain-specific. This trades extensibility for clarity, which is appropriate for the current scope of the project.
**Future evolution:** If rule types grow beyond ~10 or policies require complex cross-rule logic (e.g., "allow if scope matches AND time is within window AND the last 3 requests were not denied"), migrate to **Open Policy Agent (OPA)** with Rego policies. OPA provides a mature ecosystem: policy bundles, decision logging, partial evaluation, and a test framework. The migration path is straightforward — the `PolicyEvaluator` interface stays, the typed evaluators are replaced by an OPA client that sends the request context as input and receives allow/deny decisions. Policy configuration moves from `policies.yaml` to `.rego` files, gaining full logical expressiveness.
Policy configuration lives in `files/config/policies.yaml` with a Kubernetes-style manifest format (`apiVersion`, `kind`, `metadata`, `spec`). Rules support `$data` references for shared values (allowlists, rate limits, time windows).
## Tool Registry: Autodiscovery
Tool providers register via `@Component` implementing `ToolProvider`. The gateway discovers them at startup through Spring's component scanning — no central registration file. Adding a new tool provider is a single-class operation.
Current providers: **Jira** (read/create), **Jenkins** (deploy), **Database** (query, migrate, execute).
## Audit: Non-Optional, TSDB-Ready
Every gateway request produces an `AuditEntry` — both allowed and denied actions are logged. The data model separates **tags** (indexed dimensions: agentId, tool, action, decision) from **fields** (payload: arguments, reason, scopes, policyRuleId), making it ready for time-series database ingestion.
Current storage: NDJSON append-only file. The tag/field separation allows migration to a TSDB without changing the domain model.
**Future evolution:** Replace the NDJSON file with a time-series database such as **Prometheus** (for metrics-oriented queries and alerting integration) or **InfluxDB** (for high-cardinality event storage with native retention policies). The existing tags/fields data model maps directly to TSDB concepts — tags become indexed labels, fields become measurement values. This swap is a storage-layer change only; the `AuditStore` interface and domain model remain untouched.
## Fraud Detection
A dedicated module analyzes request patterns for anomalies: SQL injection attempts in arguments, prompt injection patterns, and repeated denial sequences (an agent hammering a denied tool). Fraud signals are recorded in audit entries alongside policy decisions.
**Future evolution:** Current detection uses deterministic pattern matching (regex, counters, heuristics). A natural next step is integrating **LLM-based verification** — feeding suspicious requests to a language model that can assess intent with semantic understanding rather than pattern matching. This enables detection of obfuscated injection attempts, novel attack vectors, and context-dependent anomalies that rule-based systems miss. The `FraudDetector` interface already supports this: add an LLM-backed implementation alongside the existing rule-based one, and compose them (rule-based as fast first pass, LLM as deeper analysis for flagged requests).
## Security Model
- **JWT authentication** with an RSA key pair generated at startup
- **Stateless sessions** — no server-side session storage
- **Credentials never exposed to tools** — the gateway holds credentials; providers receive pre-authenticated clients
- **All policy enforcement is server-side** — clients stay thin and contain no policy logic
## Client Authentication
Client credentials should be supplied through environment variables or another secret source outside the repository. The important property is simple: clients authenticate, receive scoped JWTs, and never embed downstream system credentials.
This keeps the trust boundary where it belongs:
- The client proves identity to the gateway
- The gateway issues scoped tokens
- All protected operations still require server-side evaluation
## Client Integration
Any HTTP-capable client can integrate with the gateway: an AI assistant, a CLI, a backend worker, or a small frontend. The integration model is intentionally simple: authenticate, obtain a JWT, call `/api/gateway/execute`, and handle one of three outcomes: `ALLOW`, `DENY`, or `REQUIRE_APPROVAL`.
Demo scenarios in `scenes/` cover the full policy matrix: allowed operations, scope mismatches, blocked tools, rate limits, time windows, argument validation, fraud detection (SQL injection, prompt injection, repeated denials), audit trail queries, and hot-reload of policies.

6
Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY app-monolith/build/libs/*.jar app.jar
COPY files/ files/
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

125
README.md Normal file
View File

@ -0,0 +1,125 @@
# Simple AI Gateway Tool
A policy-enforced gateway that mediates AI agent access to external systems such as Jira, Jenkins, and databases. The AI acts as a requesting client: every action must pass through server-side policy evaluation, authorization, and audit before reaching the target system.
**Stack:** Java 21, Spring Boot, Gradle, Docker
For architectural decisions and rationale, see [ARCHITECTURAL-FACTS.md](ARCHITECTURAL-FACTS.md).
## Quick Start
### Prerequisites
- Java 21 (build)
- Docker & Docker Compose (run)
### Build & Run
```bash
./start.sh
```
This builds the bootJar, starts the gateway in Docker, and verifies it's healthy at `http://localhost:8080`.
To stop:
```bash
./stop.sh
```
### Manual Build
```bash
./gradlew :app-monolith:bootJar --no-daemon
docker compose up --build -d
```
## How It Works
```
AI Client / Agent --> POST /api/gateway/execute --> Gateway
|
Authorization --+
JWT Auth -------+
Fraud Detection +
Policy Engine --+
Audit ----------+
|
Tool Provider (Jira / Jenkins / DB)
```
1. The AI authenticates via `/api/auth/token` and receives a scoped JWT
2. Every tool invocation goes through `/api/gateway/execute`
3. The gateway evaluates policies (deny-overrides), checks authorization, runs fraud detection, and logs an audit entry
4. Only if all checks pass does the request reach the tool provider
## Tool Surface
The gateway currently exposes these tool operations through its HTTP interface:
| Tool | Description |
|------|-------------|
| `jira.getTicket` / `jira.createTicket` | Read and create Jira tickets |
| `jenkins.deploy` | Trigger Jenkins deployments |
| `db.queryReadonly` | Execute read-only DB queries |
| `db.runLiquibase` | Run Liquibase migrations |
| `db.executeScript` | Execute DB scripts (intentionally blocked by policy in the default config) |
## Demo Scenes
The `scenes/` directory contains a guided walkthrough. Each scene demonstrates a specific policy behavior. Run them in order: scene 00 sets up authentication, then each subsequent scene builds on the previous.
### Setup
| # | Scene | File |
|---|-------|------|
| 00 | Setup: Authenticate & Start Gateway | [00-setup.md](scenes/00-setup.md) |
### Policy Decisions
| # | Scene | Expected Decision |
|---|-------|-------------------|
| 01 | Read-only database query | ALLOW |
| 02 | Production deployment (requires approval) | REQUIRE_APPROVAL |
| 03 | Scope mismatch (insufficient permissions) | DENY |
| 04 | Blocked tool (db.executeScript) | DENY |
| 05 | Rate limit exceeded | DENY |
| 06 | Outside time window (business hours) | DENY |
| 07 | Argument validation (project not in allowlist) | DENY |
### Fraud Detection
| # | Scene | Attack Vector |
|---|-------|---------------|
| 08 | SQL injection in query arguments | [08-fraud-sql-injection.md](scenes/08-fraud-sql-injection.md) |
| 09 | Prompt injection in tool arguments | [09-fraud-prompt-injection.md](scenes/09-fraud-prompt-injection.md) |
| 10 | Repeated denial pattern (hammering) | [10-fraud-repeated-denial.md](scenes/10-fraud-repeated-denial.md) |
### Observability
| # | Scene | What It Shows |
|---|-------|---------------|
| 11 | Audit trail query | [11-audit-trail.md](scenes/11-audit-trail.md) |
| 12 | Hot-reload policies without restart | [12-hot-reload-policies.md](scenes/12-hot-reload-policies.md) |
## Policy Configuration
Policies live in `files/config/policies.yaml`. The format uses a Kubernetes-style manifest with 6 rule types: `scope`, `environment`, `argument_allowlist`, `time_window`, `rate_limit`, `block`.
Policies support hot-reload — edit the file and the gateway picks up changes without restart (see scene 12).
## Project Structure
```
app-monolith/ # Composition root (Spring Boot app)
adapter-http-gateway/ # HTTP orchestration (/api/gateway/*)
adapter-http-security-infra/ # JWT auth & Spring Security
gateway-module-policy/ # Policy engine (6 rule evaluators)
gateway-module-authorization/ # Scope-to-permission mapping
gateway-module-audit/ # Audit trail (NDJSON, TSDB-ready)
gateway-module-fraud-detection/ # SQL injection, prompt injection, pattern detection
gateway-module-tool-registry/ # Tool autodiscovery
gateway-providers/ # Jira, Jenkins, Database providers
scenes/ # Guided demo walkthrough
files/config/ # policies.yaml
```

View File

@ -0,0 +1,16 @@
plugins {
id("java-library-conventions")
}
dependencies {
implementation(project(":main-infrastructure"))
implementation(project(":main-contract-subjects"))
implementation(project(":adapter-http-security-infra"))
implementation(project(":gateway-module-policy:policy-api"))
implementation(project(":gateway-module-authorization:authorization-api"))
implementation(project(":gateway-module-audit:audit-api"))
implementation(project(":gateway-module-fraud-detection:fraud-detection-api"))
implementation(project(":gateway-module-tool-registry:tool-registry-api"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
}

View File

@ -0,0 +1,43 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.audit.api.AuditStore;
import net.bquarkz.ai.gateway.audit.contracts.AuditEntry;
import net.bquarkz.ai.gateway.audit.contracts.AuditQuery;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.List;
@RestController
@RequestMapping("/api/audit")
@RequiredArgsConstructor
public class AuditController extends AbstractController {
private final AuditStore auditStore;
@GetMapping("/recent")
public ResponseEntity<?> recent(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(required = false) OffsetDateTime from,
@RequestParam(required = false) OffsetDateTime to,
@RequestParam(required = false) String tool,
@RequestParam(required = false) String agentId,
@RequestParam(required = false) PolicyDecision decision,
@RequestParam(required = false) String policyRuleId) {
AuditQuery query = AuditQuery.builder()
.limit(Math.min(limit, 100))
.from(from)
.to(to)
.tool(tool)
.agentId(agentId)
.decision(decision)
.policyRuleId(policyRuleId)
.build();
List<AuditEntry> entries = auditStore.query(query);
return ResponseEntity.ok(entries);
}
}

View File

@ -0,0 +1,45 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.security.TokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private static final long DEFAULT_EXPIRY_SECONDS = 3600;
private static final Map<String, String> VALID_CLIENTS = Map.of(
"claude-mcp-agent", "secret"
);
private final TokenService tokenService;
@PostMapping("/token")
public ResponseEntity<?> issueToken(@RequestBody TokenRequest request) {
String expectedSecret = VALID_CLIENTS.get(request.clientId());
if (expectedSecret == null || !expectedSecret.equals(request.clientSecret())) {
return ResponseEntity.status(401).body(Map.of("error", "invalid_client"));
}
String token = tokenService.generateToken(
request.clientId(),
request.scopes(),
"staging",
DEFAULT_EXPIRY_SECONDS
);
return ResponseEntity.ok(Map.of(
"access_token", token,
"token_type", "bearer",
"expires_in", DEFAULT_EXPIRY_SECONDS,
"scopes", request.scopes()
));
}
record TokenRequest(String clientId, String clientSecret, Set<String> scopes) {}
}

View File

@ -0,0 +1,138 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.audit.api.AuditStore;
import net.bquarkz.ai.gateway.audit.contracts.AuditEntry;
import net.bquarkz.ai.gateway.authorization.api.AuthorizationService;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.contracts.ToolExecutionResponse;
import net.bquarkz.ai.gateway.core.security.AccessGrant;
import net.bquarkz.ai.gateway.exceptions.ToolNotFoundException;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import net.bquarkz.ai.gateway.fraud.api.FraudDetector;
import net.bquarkz.ai.gateway.policy.api.PolicyEvaluator;
import net.bquarkz.ai.gateway.policy.contracts.PolicyEvaluationResult;
import net.bquarkz.ai.gateway.security.ContextBuilder;
import net.bquarkz.ai.gateway.tools.api.ToolRegistry;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/gateway")
@RequiredArgsConstructor
public class GatewayController extends AbstractController {
private final ContextBuilder contextBuilder;
private final AuthorizationService authorizationService;
private final FraudDetector fraudDetector;
private final PolicyEvaluator policyEvaluator;
private final AuditStore auditStore;
private final ToolRegistry toolRegistry;
private final RateCounter rateCounter;
@PostMapping("/execute")
public ResponseEntity<?> execute(@RequestBody ToolExecutionRequest request) {
String requestId = UUID.randomUUID().toString();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// Build context
String agentId = authentication.getName();
int currentRate = rateCounter.incrementAndGet(agentId, request.tool());
GatewayContext.RateInfo rateInfo = GatewayContext.RateInfo.builder()
.current(currentRate)
.limit(30)
.build();
GatewayContext context = contextBuilder.buildContext(authentication, rateInfo);
// Check authorization
AccessGrant grant = authorizationService.authorize(
context.getSubject().getScopes(), request.tool(), request.action());
if (!grant.isGranted()) {
logAudit(requestId, context, request, PolicyDecision.DENY, grant.getReason(), null, null);
return ResponseEntity.status(403).body(
new ToolExecutionResponse(requestId, PolicyDecision.DENY, grant.getReason(), null, null, null));
}
// Fraud detection
Optional<FraudAlert> fraudAlert = fraudDetector.analyze(request, context);
if (fraudAlert.isPresent()) {
FraudAlert alert = fraudAlert.get();
String fraudReason = "[FRAUD:" + alert.severity() + "] " + alert.reason();
logAudit(requestId, context, request, PolicyDecision.DENY, fraudReason, null, alert);
return ResponseEntity.status(403).body(Map.of(
"requestId", requestId,
"decision", "DENY",
"fraud_detected", true,
"fraud_detector", alert.detector(),
"fraud_severity", alert.severity().name(),
"reason", alert.reason()
));
}
// Evaluate policy
PolicyEvaluationResult policyResult = policyEvaluator.evaluate(request, context);
String matchedRule = policyResult.getMatchedRules().isEmpty() ? null
: String.join(", ", policyResult.getMatchedRules());
String reason = policyResult.getReasons().isEmpty() ? null
: String.join("; ", policyResult.getReasons());
if (policyResult.getDecision() == PolicyDecision.DENY) {
logAudit(requestId, context, request, PolicyDecision.DENY, reason, matchedRule, null);
return ResponseEntity.status(403).body(
new ToolExecutionResponse(requestId, PolicyDecision.DENY, reason, matchedRule, null, null));
}
if (policyResult.getDecision() == PolicyDecision.REQUIRE_APPROVAL) {
logAudit(requestId, context, request, PolicyDecision.REQUIRE_APPROVAL, reason, matchedRule, null);
return ResponseEntity.ok(new ToolExecutionResponse(requestId, PolicyDecision.REQUIRE_APPROVAL,
reason, matchedRule, null, new ToolExecutionResponse.ApprovalStatus("pending", 300)));
}
// Execute tool
ToolProvider tool = toolRegistry.findTool(request.tool())
.orElseThrow(() -> new ToolNotFoundException(request.tool()));
Map<String, Object> result = tool.execute(request.action(), request.arguments());
logAudit(requestId, context, request, PolicyDecision.ALLOW, null, matchedRule, null);
return ResponseEntity.ok(
new ToolExecutionResponse(requestId, PolicyDecision.ALLOW, null, matchedRule, result, null));
}
private void logAudit(String requestId, GatewayContext context, ToolExecutionRequest request,
PolicyDecision decision, String reason, String matchedRule, FraudAlert fraudAlert) {
auditStore.record(AuditEntry.builder()
.timestamp(OffsetDateTime.now())
.tags(AuditEntry.Tags.builder()
.agentId(context.getSubject().getAgentId())
.tool(request.tool())
.action(request.action())
.decision(decision)
.policyRuleId(fraudAlert != null ? "fraud:" + fraudAlert.detector() : matchedRule)
.build())
.fields(AuditEntry.Fields.builder()
.requestId(requestId)
.reason(reason)
.arguments(serializeJson(request.arguments()))
.scopes(serializeJson(context.getSubject().getScopes()))
.build())
.build());
}
private String serializeJson(Object value) {
if (value == null) return null;
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(value);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
return value.toString();
}
}
}

View File

@ -0,0 +1,24 @@
package net.bquarkz.ai.gateway.controllers;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
@EnableScheduling
public class RateCounter {
private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
public int incrementAndGet(String agentId, String tool) {
String key = agentId + ":" + tool;
return counters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
}
@Scheduled(fixedRate = 60000)
void resetCounters() {
counters.clear();
}
}

View File

@ -0,0 +1,22 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.tools.api.ToolRegistry;
import net.bquarkz.ai.gateway.tools.contracts.ToolDefinition;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/gateway")
@RequiredArgsConstructor
public class ToolDiscoveryController {
private final ToolRegistry toolRegistry;
@GetMapping("/tools")
public List<ToolDefinition> listTools() {
return toolRegistry.listTools();
}
}

View File

@ -0,0 +1,16 @@
plugins {
id("java-library-conventions")
}
dependencies {
api(project(":main-infrastructure"))
api(project(":main-contract-subjects"))
implementation(project(":main-service"))
implementation(Dependencies.jjwtApi)
runtimeOnly(Dependencies.jjwtImpl)
runtimeOnly(Dependencies.jjwtJackson)
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("com.nimbusds:nimbus-jose-jwt")
}

View File

@ -0,0 +1,22 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.messages.ResponsePayload;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public abstract class AbstractController {
protected <T> ResponseEntity<ResponsePayload<T>> ok(T data, String requestId) {
return ResponseEntity.ok(ResponsePayload.success(data, requestId));
}
protected <T> ResponseEntity<ResponsePayload<T>> forbidden(String reason, String requestId) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ResponsePayload.error(reason, requestId));
}
protected <T> ResponseEntity<ResponsePayload<T>> badRequest(String reason, String requestId) {
return ResponseEntity.badRequest()
.body(ResponsePayload.error(reason, requestId));
}
}

View File

@ -0,0 +1,44 @@
package net.bquarkz.ai.gateway.controllers;
import net.bquarkz.ai.gateway.exceptions.*;
import net.bquarkz.ai.gateway.messages.ResponsePayload;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.UUID;
@ControllerAdvice
public class GenericExceptionHandler {
@ExceptionHandler(PolicyDeniedException.class)
public ResponseEntity<ResponsePayload<Void>> handlePolicyDenied(PolicyDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ResponsePayload.error(ex.getMessage(), UUID.randomUUID().toString()));
}
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ResponsePayload<Void>> handleUnauthorized(UnauthorizedException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ResponsePayload.error(ex.getMessage(), UUID.randomUUID().toString()));
}
@ExceptionHandler(ToolNotFoundException.class)
public ResponseEntity<ResponsePayload<Void>> handleToolNotFound(ToolNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ResponsePayload.error(ex.getMessage(), UUID.randomUUID().toString()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ResponsePayload<Void>> handleValidation(ValidationException ex) {
return ResponseEntity.badRequest()
.body(ResponsePayload.error(ex.getMessage(), UUID.randomUUID().toString()));
}
@ExceptionHandler(GatewayException.class)
public ResponseEntity<ResponsePayload<Void>> handleGateway(GatewayException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ResponsePayload.error(ex.getMessage(), UUID.randomUUID().toString()));
}
}

View File

@ -0,0 +1,36 @@
package net.bquarkz.ai.gateway.security;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Component
public class ContextBuilder {
public GatewayContext buildContext(Authentication authentication, GatewayContext.RateInfo rateInfo) {
Jwt jwt = (Jwt) authentication.getPrincipal();
String agentId = jwt.getSubject();
List<String> scopesList = jwt.getClaimAsStringList("scopes");
Set<String> scopes = scopesList != null ? new HashSet<>(scopesList) : Set.of();
String environment = jwt.getClaimAsString("environment");
return GatewayContext.builder()
.subject(GatewayContext.Subject.builder()
.agentId(agentId)
.scopes(scopes)
.build())
.context(GatewayContext.ContextData.builder()
.environment(environment != null ? environment : "local")
.timestamp(OffsetDateTime.now())
.rate(rateInfo)
.build())
.build();
}
}

View File

@ -0,0 +1,30 @@
package net.bquarkz.ai.gateway.security;
import org.springframework.stereotype.Component;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
@Component
public class SigningKeyProvider {
private final KeyPair keyPair;
public SigningKeyProvider() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
this.keyPair = generator.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException("Failed to generate RSA key pair", e);
}
}
public RSAPublicKey getPublicKey() {
return (RSAPublicKey) keyPair.getPublic();
}
public RSAPrivateKey getPrivateKey() {
return (RSAPrivateKey) keyPair.getPrivate();
}
}

View File

@ -0,0 +1,38 @@
package net.bquarkz.ai.gateway.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class TokenService {
private final SigningKeyProvider signingKeyProvider;
public String generateToken(String agentId, Set<String> scopes, String environment, long expiresInSeconds) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expiresInSeconds * 1000);
return Jwts.builder()
.subject(agentId)
.claim("scopes", List.copyOf(scopes))
.claim("environment", environment)
.issuedAt(now)
.expiration(expiry)
.signWith(signingKeyProvider.getPrivateKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKeyProvider.getPublicKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@ -0,0 +1,37 @@
package net.bquarkz.ai.gateway.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfiguration {
private final SigningKeyProvider signingKeyProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder())))
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(signingKeyProvider.getPublicKey()).build();
}
}

View File

@ -0,0 +1,34 @@
plugins {
id("java-application-conventions")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
application {
mainClass.set("net.bquarkz.ai.gateway.GatewayApplication")
}
dependencies {
implementation(project(":main-infrastructure"))
implementation(project(":main-service"))
implementation(project(":main-contract-subjects"))
implementation(project(":adapter-http-security-infra"))
implementation(project(":adapter-http-gateway"))
implementation(project(":gateway-module-policy:policy-domain"))
implementation(project(":gateway-module-authorization:authorization-domain"))
implementation(project(":gateway-module-audit:audit-domain"))
implementation(project(":gateway-module-fraud-detection:fraud-detection-domain"))
implementation(project(":gateway-module-tool-registry:tool-registry-domain"))
implementation(project(":gateway-providers:jira-provider"))
implementation(project(":gateway-providers:jenkins-provider"))
implementation(project(":gateway-providers:database-provider"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
testImplementation(project(":test-infrastructure"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
}

View File

@ -0,0 +1,11 @@
package net.bquarkz.ai.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"net.bquarkz.ai.gateway"})
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@ -0,0 +1,10 @@
server:
port: 8080
spring:
application:
name: ai-gateway-tool
gateway:
policy:
config-path: files/config/policies.yaml

3
build.gradle.kts Normal file
View File

@ -0,0 +1,3 @@
plugins {
id("java-common-conventions")
}

13
buildSrc/build.gradle.kts Normal file
View File

@ -0,0 +1,13 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("org.springframework.boot:spring-boot-gradle-plugin:4.0.4")
implementation("io.spring.gradle:dependency-management-plugin:1.1.7")
}

View File

@ -0,0 +1,14 @@
object Dependencies {
const val lombokDep = "org.projectlombok:lombok:${Versions.lombok}"
const val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind:${Versions.jackson}"
const val jacksonYaml = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${Versions.jackson}"
const val snakeyaml = "org.yaml:snakeyaml:${Versions.snakeyaml}"
const val jjwtApi = "io.jsonwebtoken:jjwt-api:${Versions.jjwt}"
const val jjwtImpl = "io.jsonwebtoken:jjwt-impl:${Versions.jjwt}"
const val jjwtJackson = "io.jsonwebtoken:jjwt-jackson:${Versions.jjwt}"
const val slf4jApi = "org.slf4j:slf4j-api:2.0.16"
const val junitApi = "org.junit.jupiter:junit-jupiter-api:${Versions.junit}"
const val junitEngine = "org.junit.jupiter:junit-jupiter-engine:${Versions.junit}"
const val mockitoCore = "org.mockito:mockito-core:${Versions.mockito}"
const val mockitoJunit = "org.mockito:mockito-junit-jupiter:${Versions.mockito}"
}

View File

@ -0,0 +1,11 @@
object Versions {
const val java = "21"
const val springBoot = "4.0.4"
const val jjwt = "0.12.6"
const val lombok = "1.18.36"
const val jackson = "2.18.2"
const val snakeyaml = "2.3"
const val junit = "5.11.4"
const val mockito = "5.15.2"
const val jacoco = "0.8.12"
}

View File

@ -0,0 +1,4 @@
plugins {
id("java-library-conventions")
application
}

View File

@ -0,0 +1,48 @@
plugins {
java
jacoco
id("io.spring.dependency-management")
}
dependencyManagement {
imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:${Versions.springBoot}")
}
}
group = "net.bquarkz.ai.gateway"
version = "0.1.0-SNAPSHOT"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(Versions.java.toInt()))
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.addAll(listOf("-Xlint:unchecked", "-Xlint:deprecation"))
}
dependencies {
compileOnly(Dependencies.lombokDep)
annotationProcessor(Dependencies.lombokDep)
implementation(Dependencies.slf4jApi)
testImplementation(Dependencies.junitApi)
testRuntimeOnly(Dependencies.junitEngine)
testImplementation(Dependencies.mockitoCore)
testImplementation(Dependencies.mockitoJunit)
}
tasks.test {
useJUnitPlatform()
maxParallelForks = 4
}
jacoco {
toolVersion = Versions.jacoco
}

View File

@ -0,0 +1,4 @@
plugins {
id("java-common-conventions")
`java-library`
}

View File

@ -0,0 +1,11 @@
plugins {
id("java-common-conventions")
`java-library`
}
dependencies {
api(Dependencies.junitApi)
api(Dependencies.junitEngine)
api(Dependencies.mockitoCore)
api(Dependencies.mockitoJunit)
}

9
docker-compose.yml Normal file
View File

@ -0,0 +1,9 @@
services:
gateway:
build: .
ports:
- "8080:8080"
volumes:
- ./audit.ndjson:/app/audit.ndjson
environment:
- SPRING_PROFILES_ACTIVE=local

View File

@ -0,0 +1,114 @@
# =============================================================================
# AI Gateway Policy — OPA/Rego Reference Implementation
# =============================================================================
# This file is a reference-only translation of policies.yaml into OPA Rego.
# It is NOT processed by the gateway; it exists to show how the same rules
# would look if evaluated by Open Policy Agent.
# =============================================================================
package gateway.policy
import rego.v1
# ---------- data ----------
allowed_projects := {"SAFE", "PLATFORM", "OPS"}
allowed_changelogs := {"release_*.xml", "hotfix_*.xml"}
business_hours := {"start": "08:00", "end": "18:00", "timezone": "Europe/Oslo"}
rate_limit_default := 30
# ---------- default decision ----------
default decision := "deny"
# ---------- scope-based allow ----------
decision := "allow" if {
input.tool == "jira.*" # pattern: starts_with(input.tool, "jira.")
startswith(input.tool, "jira.")
"jira.read" in input.subject.scopes
input.action in {"read", "get", "list", "search"}
}
decision := "allow" if {
startswith(input.tool, "jira.")
"jira.write" in input.subject.scopes
input.action in {"create", "update", "delete"}
}
decision := "allow" if {
input.tool == "db.queryReadonly"
"db.read" in input.subject.scopes
}
# ---------- environment-based rules ----------
decision := "allow" if {
input.tool == "jenkins.deploy"
input.context.environment == "staging"
}
decision := "require_approval" if {
input.tool == "jenkins.deploy"
input.context.environment == "prod"
}
# ---------- argument allowlist ----------
decision := "deny" if {
input.tool == "jira.createTicket"
not input.arguments.project in allowed_projects
}
decision := "deny" if {
input.tool == "db.runLiquibase"
not glob_match_any(allowed_changelogs, input.arguments.changelog)
}
# ---------- block rule ----------
decision := "deny" if {
input.tool == "db.executeScript"
}
# ---------- rate limit ----------
decision := "deny" if {
input.context.rate.current > rate_limit_default
}
# ---------- time window ----------
decision := "deny" if {
input.tool == "jenkins.deploy"
not within_business_hours(input.context.timestamp)
}
# ---------- helpers ----------
within_business_hours(timestamp) if {
# Simplified: real implementation would parse timestamp and convert to Europe/Oslo
hour := to_number(substring(timestamp, 11, 2))
hour >= 8
hour < 18
}
glob_match_any(patterns, value) if {
some pattern in patterns
glob.match(pattern, ["/"], value)
}
# ---------- reason ----------
reason := "Project not in allowlist" if {
input.tool == "jira.createTicket"
not input.arguments.project in allowed_projects
}
reason := "Changelog not in allowlist" if {
input.tool == "db.runLiquibase"
not glob_match_any(allowed_changelogs, input.arguments.changelog)
}
reason := "db.executeScript is blocked by policy" if {
input.tool == "db.executeScript"
}
reason := "Rate limit exceeded" if {
input.context.rate.current > rate_limit_default
}
reason := "Deployments only allowed during business hours" if {
input.tool == "jenkins.deploy"
not within_business_hours(input.context.timestamp)
}

View File

@ -0,0 +1,73 @@
apiVersion: gateway.bquarkz.net/v1
kind: PolicyConfig
metadata:
name: ai-gateway-policies
spec:
defaultDecision: deny
data:
allowed_projects: [SAFE, PLATFORM, OPS]
allowed_changelogs: ["release_*.xml", "hotfix_*.xml"]
business_hours:
start: "08:00"
end: "18:00"
timezone: "Europe/Oslo"
rate_limits:
default: 30
db.queryReadonly: 60
rules:
- name: env-deploy-rules
type: environment
tool: jenkins.deploy
when:
staging: allow
prod: require_approval
- name: allow-jira-read
type: scope
decision: allow
tool: "jira.*"
required_scope: jira.read
actions: [read, get, list, search]
- name: allow-jira-write
type: scope
decision: allow
tool: "jira.*"
required_scope: jira.write
actions: [create, update, delete]
- name: allow-db-readonly
type: scope
decision: allow
tool: db.queryReadonly
required_scope: db.read
- name: restrict-jira-projects
type: argument_allowlist
decision: deny
tool: jira.createTicket
argument: project
allowlist: "$data.allowed_projects"
reason: "Project not in allowlist"
- name: restrict-liquibase-changelogs
type: argument_allowlist
decision: deny
tool: db.runLiquibase
argument: changelog
allowlist: "$data.allowed_changelogs"
reason: "Changelog not in allowlist"
- name: deny-db-execute-script
type: block
tool: db.executeScript
reason: "db.executeScript is blocked by policy"
- name: rate-limit-default
type: rate_limit
decision: deny
tool: "*"
max_per_minute: 30
reason: "Rate limit exceeded"
- name: deny-deploy-outside-hours
type: time_window
decision: deny
tool: jenkins.deploy
window:
start: "08:00"
end: "18:00"
timezone: "Europe/Oslo"
reason: "Deployments only allowed during business hours"

View File

@ -0,0 +1,4 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":gateway-module-audit:audit-contract-subjects"))
}

View File

@ -0,0 +1,12 @@
package net.bquarkz.ai.gateway.audit.api;
import net.bquarkz.ai.gateway.audit.contracts.AuditEntry;
import net.bquarkz.ai.gateway.audit.contracts.AuditQuery;
import java.util.List;
public interface AuditStore {
void record(AuditEntry entry);
List<AuditEntry> query(AuditQuery query);
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-infrastructure"))
api(project(":main-contract-subjects"))
}

View File

@ -0,0 +1,38 @@
package net.bquarkz.ai.gateway.audit.contracts;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import java.time.OffsetDateTime;
@Value
@Builder
@Jacksonized
public class AuditEntry {
OffsetDateTime timestamp;
Tags tags;
Fields fields;
@Value
@Builder
@Jacksonized
public static class Tags {
String agentId;
String tool;
String action;
PolicyDecision decision;
String policyRuleId;
}
@Value
@Builder
@Jacksonized
public static class Fields {
String requestId;
String reason;
String arguments;
String scopes;
}
}

View File

@ -0,0 +1,20 @@
package net.bquarkz.ai.gateway.audit.contracts;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.Builder;
import lombok.Value;
import java.time.OffsetDateTime;
@Value
@Builder
public class AuditQuery {
@Builder.Default int limit = 20;
OffsetDateTime from;
OffsetDateTime to;
// tag filters
String tool;
String agentId;
PolicyDecision decision;
String policyRuleId;
}

View File

@ -0,0 +1,7 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-audit:audit-api"))
implementation(Dependencies.jacksonDatabind)
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,93 @@
package net.bquarkz.ai.gateway.audit.domain;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import net.bquarkz.ai.gateway.audit.api.AuditStore;
import net.bquarkz.ai.gateway.audit.contracts.AuditEntry;
import net.bquarkz.ai.gateway.audit.contracts.AuditQuery;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@Service
public class NdjsonAuditStore implements AuditStore {
private static final Logger log = LoggerFactory.getLogger(NdjsonAuditStore.class);
private final ObjectMapper objectMapper;
private final Path filePath;
public NdjsonAuditStore(@Value("${gateway.audit.file-path:audit.ndjson}") String filePath) {
this.filePath = Path.of(filePath);
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
@Override
public synchronized void record(AuditEntry entry) {
try {
String json = objectMapper.writeValueAsString(entry);
Files.writeString(filePath, json + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
log.error("Failed to write audit entry", e);
}
}
@Override
public List<AuditEntry> query(AuditQuery query) {
if (!Files.exists(filePath)) {
return List.of();
}
try {
List<String> allLines = Files.readAllLines(filePath);
LinkedList<AuditEntry> entries = new LinkedList<>();
for (int i = allLines.size() - 1; i >= 0 && entries.size() < query.getLimit(); i--) {
String line = allLines.get(i).trim();
if (line.isEmpty()) continue;
AuditEntry entry = objectMapper.readValue(line, AuditEntry.class);
if (matches(entry, query)) {
entries.add(entry);
}
}
return Collections.unmodifiableList(entries);
} catch (IOException e) {
log.error("Failed to read audit entries", e);
return List.of();
}
}
private boolean matches(AuditEntry entry, AuditQuery query) {
if (query.getFrom() != null && entry.getTimestamp().isBefore(query.getFrom())) {
return false;
}
if (query.getTo() != null && entry.getTimestamp().isAfter(query.getTo())) {
return false;
}
AuditEntry.Tags tags = entry.getTags();
if (query.getTool() != null && !query.getTool().equals(tags.getTool())) {
return false;
}
if (query.getAgentId() != null && !query.getAgentId().equals(tags.getAgentId())) {
return false;
}
if (query.getDecision() != null && query.getDecision() != tags.getDecision()) {
return false;
}
if (query.getPolicyRuleId() != null && !query.getPolicyRuleId().equals(tags.getPolicyRuleId())) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-infrastructure"))
api(project(":gateway-module-authorization:authorization-contract-subjects"))
}

View File

@ -0,0 +1,8 @@
package net.bquarkz.ai.gateway.authorization.api;
import net.bquarkz.ai.gateway.core.security.AccessGrant;
import java.util.Set;
public interface AuthorizationService {
AccessGrant authorize(Set<String> scopes, String tool, String action);
}

View File

@ -0,0 +1,2 @@
plugins { id("java-library-conventions") }
dependencies { implementation(project(":main-infrastructure")) }

View File

@ -0,0 +1,22 @@
package net.bquarkz.ai.gateway.authorization.contracts;
import lombok.Value;
import java.util.Set;
@Value
public class Permission {
String toolPattern;
Set<String> allowedActions;
public boolean matchesTool(String toolName) {
if (toolPattern.endsWith(".*")) {
String prefix = toolPattern.substring(0, toolPattern.length() - 2);
return toolName.startsWith(prefix + ".");
}
return toolPattern.equals(toolName) || toolPattern.equals("*");
}
public boolean allowsAction(String action) {
return allowedActions.contains("*") || allowedActions.contains(action);
}
}

View File

@ -0,0 +1,12 @@
package net.bquarkz.ai.gateway.authorization.contracts;
import lombok.Value;
@Value
public class Scope {
String name;
public static Scope of(String name) {
return new Scope(name);
}
}

View File

@ -0,0 +1,7 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-authorization:authorization-api"))
implementation(project(":gateway-module-authorization:authorization-shared"))
implementation(project(":gateway-module-authorization:authorization-model-and-data"))
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,28 @@
package net.bquarkz.ai.gateway.authorization.domain;
import net.bquarkz.ai.gateway.authorization.api.AuthorizationService;
import net.bquarkz.ai.gateway.authorization.contracts.Permission;
import net.bquarkz.ai.gateway.authorization.internal.ScopeResolver;
import net.bquarkz.ai.gateway.core.security.AccessGrant;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AuthorizationServiceImpl implements AuthorizationService {
private final ScopeResolver scopeResolver;
@Override
public AccessGrant authorize(Set<String> scopes, String tool, String action) {
List<Permission> permissions = scopeResolver.resolve(scopes);
for (Permission permission : permissions) {
if (permission.matchesTool(tool) && permission.allowsAction(action)) {
return AccessGrant.allowed();
}
}
return AccessGrant.denied("No scope grants access to " + tool + ":" + action);
}
}

View File

@ -0,0 +1,44 @@
package net.bquarkz.ai.gateway.authorization.domain;
import net.bquarkz.ai.gateway.authorization.contracts.Permission;
import net.bquarkz.ai.gateway.authorization.internal.ScopeResolver;
import net.bquarkz.ai.gateway.authorization.model.ScopeMapping;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class ScopeResolverImpl implements ScopeResolver {
private static final Map<String, ScopeMapping> SCOPE_MAPPINGS = Map.of(
"jira.read", new ScopeMapping("jira.read", List.of(
new Permission("jira.*", Set.of("read", "get", "list", "search"))
)),
"jira.write", new ScopeMapping("jira.write", List.of(
new Permission("jira.*", Set.of("create", "update", "delete"))
)),
"jenkins.deploy", new ScopeMapping("jenkins.deploy", List.of(
new Permission("jenkins.deploy", Set.of("deploy"))
)),
"db.read", new ScopeMapping("db.read", List.of(
new Permission("db.queryReadonly", Set.of("*"))
)),
"db.migrate", new ScopeMapping("db.migrate", List.of(
new Permission("db.runLiquibase", Set.of("*"))
))
);
@Override
public List<Permission> resolve(Set<String> scopes) {
List<Permission> permissions = new ArrayList<>();
for (String scope : scopes) {
ScopeMapping mapping = SCOPE_MAPPINGS.get(scope);
if (mapping != null) {
permissions.addAll(mapping.getPermissions());
}
}
return permissions;
}
}

View File

@ -0,0 +1,2 @@
plugins { id("java-library-conventions") }
dependencies { implementation(project(":gateway-module-authorization:authorization-contract-subjects")) }

View File

@ -0,0 +1,11 @@
package net.bquarkz.ai.gateway.authorization.model;
import net.bquarkz.ai.gateway.authorization.contracts.Permission;
import lombok.Value;
import java.util.List;
@Value
public class ScopeMapping {
String scopeName;
List<Permission> permissions;
}

View File

@ -0,0 +1,4 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":gateway-module-authorization:authorization-contract-subjects"))
}

View File

@ -0,0 +1,9 @@
package net.bquarkz.ai.gateway.authorization.internal;
import net.bquarkz.ai.gateway.authorization.contracts.Permission;
import java.util.List;
import java.util.Set;
public interface ScopeResolver {
List<Permission> resolve(Set<String> scopes);
}

View File

@ -0,0 +1,4 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-contract-subjects"))
}

View File

@ -0,0 +1,10 @@
package net.bquarkz.ai.gateway.fraud.api;
public record FraudAlert(
String detector,
String reason,
Severity severity,
String matchedPattern
) {
public enum Severity { LOW, MEDIUM, HIGH, CRITICAL }
}

View File

@ -0,0 +1,10 @@
package net.bquarkz.ai.gateway.fraud.api;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import java.util.Optional;
public interface FraudDetector {
Optional<FraudAlert> analyze(ToolExecutionRequest request, GatewayContext context);
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-fraud-detection:fraud-detection-api"))
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,11 @@
package net.bquarkz.ai.gateway.fraud.domain;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import java.util.Optional;
public interface FraudCheck {
Optional<FraudAlert> check(ToolExecutionRequest request, GatewayContext context);
}

View File

@ -0,0 +1,37 @@
package net.bquarkz.ai.gateway.fraud.domain;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import net.bquarkz.ai.gateway.fraud.api.FraudDetector;
import net.bquarkz.ai.gateway.fraud.domain.detectors.PromptInjectionDetector;
import net.bquarkz.ai.gateway.fraud.domain.detectors.RepeatedDenialDetector;
import net.bquarkz.ai.gateway.fraud.domain.detectors.SqlInjectionDetector;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class FraudDetectorImpl implements FraudDetector {
private final List<FraudCheck> checks;
public FraudDetectorImpl(RepeatedDenialDetector repeatedDenialDetector) {
this.checks = List.of(
new SqlInjectionDetector(),
new PromptInjectionDetector(),
repeatedDenialDetector
);
}
@Override
public Optional<FraudAlert> analyze(ToolExecutionRequest request, GatewayContext context) {
for (FraudCheck check : checks) {
Optional<FraudAlert> alert = check.check(request, context);
if (alert.isPresent()) {
return alert;
}
}
return Optional.empty();
}
}

View File

@ -0,0 +1,45 @@
package net.bquarkz.ai.gateway.fraud.domain.detectors;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import net.bquarkz.ai.gateway.fraud.domain.FraudCheck;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
public class PromptInjectionDetector implements FraudCheck {
private static final List<Pattern> PROMPT_INJECTION_PATTERNS = List.of(
Pattern.compile("(?i)ignore\\s+(all\\s+)?(previous|prior|above)\\s+(instructions|rules|constraints)"),
Pattern.compile("(?i)you\\s+are\\s+now\\s+(a|an|the)"),
Pattern.compile("(?i)system\\s*prompt\\s*:"),
Pattern.compile("(?i)\\bdo\\s+not\\s+follow\\s+(the|your)\\s+(rules|instructions|policy)"),
Pattern.compile("(?i)override\\s+(security|policy|safety)"),
Pattern.compile("(?i)pretend\\s+(you|that)\\s+(are|have|can)"),
Pattern.compile("(?i)jailbreak"),
Pattern.compile("(?i)\\bact\\s+as\\s+(a|an)\\s+(admin|root|superuser)")
);
@Override
public Optional<FraudAlert> check(ToolExecutionRequest request, GatewayContext context) {
if (request.arguments() == null) {
return Optional.empty();
}
for (Object value : request.arguments().values()) {
if (value instanceof String strValue) {
for (Pattern pattern : PROMPT_INJECTION_PATTERNS) {
if (pattern.matcher(strValue).find()) {
return Optional.of(new FraudAlert(
"prompt-injection",
"Potential prompt injection detected in argument",
FraudAlert.Severity.HIGH,
pattern.pattern()
));
}
}
}
}
return Optional.empty();
}
}

View File

@ -0,0 +1,37 @@
package net.bquarkz.ai.gateway.fraud.domain.detectors;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import net.bquarkz.ai.gateway.fraud.domain.FraudCheck;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class RepeatedDenialDetector implements FraudCheck {
private static final int DENIAL_THRESHOLD = 5;
private final ConcurrentHashMap<String, AtomicInteger> denialCounters = new ConcurrentHashMap<>();
@Override
public Optional<FraudAlert> check(ToolExecutionRequest request, GatewayContext context) {
String key = context.getSubject().getAgentId() + ":" + request.tool();
AtomicInteger counter = denialCounters.get(key);
if (counter != null && counter.get() >= DENIAL_THRESHOLD) {
return Optional.of(new FraudAlert(
"repeated-denial",
"Agent has been denied " + counter.get() + " times for tool " + request.tool() + " — possible brute-force attempt",
FraudAlert.Severity.MEDIUM,
"threshold=" + DENIAL_THRESHOLD
));
}
return Optional.empty();
}
public void recordDenial(String agentId, String tool) {
String key = agentId + ":" + tool;
denialCounters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
}
}

View File

@ -0,0 +1,45 @@
package net.bquarkz.ai.gateway.fraud.domain.detectors;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.fraud.api.FraudAlert;
import net.bquarkz.ai.gateway.fraud.domain.FraudCheck;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
public class SqlInjectionDetector implements FraudCheck {
private static final List<Pattern> SQL_INJECTION_PATTERNS = List.of(
Pattern.compile("(?i)\\b(DROP|ALTER|TRUNCATE)\\s+(TABLE|DATABASE|INDEX)\\b"),
Pattern.compile("(?i)\\bDELETE\\s+FROM\\b"),
Pattern.compile("(?i)\\bUNION\\s+(ALL\\s+)?SELECT\\b"),
Pattern.compile("(?i)\\bINSERT\\s+INTO\\b.*\\bSELECT\\b"),
Pattern.compile("(?i);\\s*(DROP|DELETE|UPDATE|INSERT|ALTER|TRUNCATE)\\b"),
Pattern.compile("--\\s*$", Pattern.MULTILINE),
Pattern.compile("(?i)\\bEXEC(UTE)?\\s+(xp_|sp_)"),
Pattern.compile("(?i)\\b(GRANT|REVOKE)\\s+(ALL|SELECT|INSERT|UPDATE|DELETE)")
);
@Override
public Optional<FraudAlert> check(ToolExecutionRequest request, GatewayContext context) {
if (request.arguments() == null) {
return Optional.empty();
}
for (Object value : request.arguments().values()) {
if (value instanceof String strValue) {
for (Pattern pattern : SQL_INJECTION_PATTERNS) {
if (pattern.matcher(strValue).find()) {
return Optional.of(new FraudAlert(
"sql-injection",
"Potential SQL injection detected in argument",
FraudAlert.Severity.CRITICAL,
pattern.pattern()
));
}
}
}
}
return Optional.empty();
}
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-contract-subjects"))
api(project(":gateway-module-policy:policy-contract-subjects"))
}

View File

@ -0,0 +1,9 @@
package net.bquarkz.ai.gateway.policy.api;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyEvaluationResult;
public interface PolicyEvaluator {
PolicyEvaluationResult evaluate(ToolExecutionRequest request, GatewayContext context);
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-infrastructure"))
api(project(":main-contract-subjects"))
}

View File

@ -0,0 +1,21 @@
package net.bquarkz.ai.gateway.policy.contracts;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class PolicyConfig {
private String apiVersion;
private String kind;
private Map<String, String> metadata;
private Spec spec;
@Data
public static class Spec {
private PolicyDecision defaultDecision;
private Map<String, Object> data;
private List<Map<String, Object>> rules;
}
}

View File

@ -0,0 +1,30 @@
package net.bquarkz.ai.gateway.policy.contracts;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.Builder;
import lombok.Value;
import java.util.List;
@Value
@Builder
public class PolicyEvaluationResult {
PolicyDecision decision;
List<String> matchedRules;
List<String> reasons;
public static PolicyEvaluationResult allow() {
return PolicyEvaluationResult.builder()
.decision(PolicyDecision.ALLOW)
.matchedRules(List.of())
.reasons(List.of())
.build();
}
public static PolicyEvaluationResult deny(String rule, String reason) {
return PolicyEvaluationResult.builder()
.decision(PolicyDecision.DENY)
.matchedRules(List.of(rule))
.reasons(List.of(reason))
.build();
}
}

View File

@ -0,0 +1,17 @@
package net.bquarkz.ai.gateway.policy.contracts;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
public class PolicyRule {
private String name;
private String type;
private String tool;
private PolicyDecision decision;
private String reason;
}

View File

@ -0,0 +1,8 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-policy:policy-api"))
implementation(project(":gateway-module-policy:policy-shared"))
implementation(project(":gateway-module-policy:policy-model-and-data"))
implementation(project(":main-contract-subjects"))
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,95 @@
package net.bquarkz.ai.gateway.policy.domain;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.policy.contracts.PolicyConfig;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.model.*;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Component
public class PolicyConfigParser {
@SuppressWarnings("unchecked")
public List<PolicyRule> parse(PolicyConfig config) {
List<PolicyRule> rules = new ArrayList<>();
Map<String, Object> data = config.getSpec().getData();
for (Map<String, Object> ruleMap : config.getSpec().getRules()) {
String type = (String) ruleMap.get("type");
String name = (String) ruleMap.get("name");
String tool = (String) ruleMap.get("tool");
PolicyDecision decision = ruleMap.containsKey("decision")
? PolicyDecision.valueOf(((String) ruleMap.get("decision")).toUpperCase())
: null;
String reason = (String) ruleMap.get("reason");
PolicyRule rule = switch (type) {
case "scope" -> ScopeRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.requiredScope((String) ruleMap.get("required_scope"))
.actions((List<String>) ruleMap.get("actions"))
.build();
case "environment" -> {
Map<String, String> whenRaw = (Map<String, String>) ruleMap.get("when");
Map<String, PolicyDecision> when = new java.util.LinkedHashMap<>();
whenRaw.forEach((k, v) -> when.put(k, PolicyDecision.valueOf(v.toUpperCase())));
yield EnvironmentRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.when(when)
.build();
}
case "argument_allowlist" -> {
List<String> allowlist = resolveList(ruleMap.get("allowlist"), data);
yield ArgumentAllowlistRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.argument((String) ruleMap.get("argument"))
.allowlist(allowlist)
.build();
}
case "time_window" -> {
Map<String, String> windowMap = (Map<String, String>) ruleMap.get("window");
TimeWindowRule.TimeWindow window = new TimeWindowRule.TimeWindow();
window.setStart(windowMap.get("start"));
window.setEnd(windowMap.get("end"));
window.setTimezone(windowMap.get("timezone"));
yield TimeWindowRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.window(window)
.build();
}
case "rate_limit" -> RateLimitRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.maxPerMinute((Integer) ruleMap.get("max_per_minute"))
.build();
case "block" -> BlockRule.builder()
.name(name).type(type).tool(tool).decision(decision).reason(reason)
.build();
default -> throw new IllegalArgumentException("Unknown rule type: " + type);
};
rules.add(rule);
}
return rules;
}
@SuppressWarnings("unchecked")
private List<String> resolveList(Object value, Map<String, Object> data) {
if (value instanceof String strVal && strVal.startsWith("$data.")) {
String key = strVal.substring("$data.".length());
Object resolved = data.get(key);
if (resolved instanceof List) {
return (List<String>) resolved;
}
throw new IllegalArgumentException("$data." + key + " is not a list");
}
if (value instanceof List) {
return (List<String>) value;
}
throw new IllegalArgumentException("Expected list or $data reference, got: " + value);
}
}

View File

@ -0,0 +1,102 @@
package net.bquarkz.ai.gateway.policy.domain;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.api.PolicyEvaluator;
import net.bquarkz.ai.gateway.policy.contracts.PolicyConfig;
import net.bquarkz.ai.gateway.policy.contracts.PolicyEvaluationResult;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
@Service
@RequiredArgsConstructor
public class PolicyEvaluatorImpl implements PolicyEvaluator {
private final Supplier<PolicyConfig> policyConfigSupplier;
private final PolicyConfigParser configParser;
private final List<RuleEvaluator> ruleEvaluators;
@Override
public PolicyEvaluationResult evaluate(ToolExecutionRequest request, GatewayContext context) {
PolicyConfig policyConfig = policyConfigSupplier.get();
List<PolicyRule> loadedRules = configParser.parse(policyConfig);
PolicyDecision defaultDecision = policyConfig.getSpec().getDefaultDecision();
List<String> denyRules = new ArrayList<>();
List<String> denyReasons = new ArrayList<>();
List<String> approvalRules = new ArrayList<>();
List<String> approvalReasons = new ArrayList<>();
List<String> allowRules = new ArrayList<>();
for (PolicyRule rule : loadedRules) {
if (!matchesTool(rule.getTool(), request.tool())) {
continue;
}
for (RuleEvaluator evaluator : ruleEvaluators) {
if (evaluator.supports(rule)) {
PolicyDecision decision = evaluator.evaluate(rule, request, context);
if (decision != null) {
switch (decision) {
case DENY -> {
denyRules.add(rule.getName());
denyReasons.add(rule.getReason() != null ? rule.getReason() : rule.getName());
}
case REQUIRE_APPROVAL -> {
approvalRules.add(rule.getName());
approvalReasons.add(rule.getReason() != null ? rule.getReason() : rule.getName());
}
case ALLOW -> allowRules.add(rule.getName());
}
}
break;
}
}
}
// Deny-overrides strategy
if (!denyRules.isEmpty()) {
return PolicyEvaluationResult.builder()
.decision(PolicyDecision.DENY)
.matchedRules(denyRules)
.reasons(denyReasons)
.build();
}
if (!approvalRules.isEmpty()) {
return PolicyEvaluationResult.builder()
.decision(PolicyDecision.REQUIRE_APPROVAL)
.matchedRules(approvalRules)
.reasons(approvalReasons)
.build();
}
if (!allowRules.isEmpty()) {
return PolicyEvaluationResult.builder()
.decision(PolicyDecision.ALLOW)
.matchedRules(allowRules)
.reasons(List.of())
.build();
}
return PolicyEvaluationResult.builder()
.decision(defaultDecision)
.matchedRules(List.of())
.reasons(List.of("No matching rules; default decision applied"))
.build();
}
private boolean matchesTool(String pattern, String toolName) {
if (pattern == null || pattern.equals("*")) {
return true;
}
if (pattern.endsWith(".*")) {
String prefix = pattern.substring(0, pattern.length() - 2);
return toolName.startsWith(prefix + ".");
}
return pattern.equals(toolName);
}
}

View File

@ -0,0 +1,45 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.ArgumentAllowlistRule;
import org.springframework.stereotype.Component;
@Component
public class ArgumentAllowlistRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof ArgumentAllowlistRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
ArgumentAllowlistRule allowlistRule = (ArgumentAllowlistRule) rule;
if (request.arguments() == null) {
return null;
}
Object value = request.arguments().get(allowlistRule.getArgument());
if (value == null) {
return null;
}
String strValue = value.toString();
boolean allowed = allowlistRule.getAllowlist().stream()
.anyMatch(pattern -> matchesGlob(pattern, strValue));
if (!allowed) {
return PolicyDecision.DENY;
}
return null;
}
private boolean matchesGlob(String pattern, String value) {
if (pattern.contains("*")) {
String regex = pattern.replace(".", "\\.").replace("*", ".*");
return value.matches(regex);
}
return pattern.equals(value);
}
}

View File

@ -0,0 +1,23 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.BlockRule;
import org.springframework.stereotype.Component;
@Component
public class BlockRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof BlockRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
return PolicyDecision.DENY;
}
}

View File

@ -0,0 +1,25 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.EnvironmentRule;
import org.springframework.stereotype.Component;
@Component
public class EnvironmentRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof EnvironmentRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
EnvironmentRule envRule = (EnvironmentRule) rule;
String environment = context.getContext().getEnvironment();
return envRule.getWhen().get(environment);
}
}

View File

@ -0,0 +1,28 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.RateLimitRule;
import org.springframework.stereotype.Component;
@Component
public class RateLimitRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof RateLimitRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
RateLimitRule rateLimitRule = (RateLimitRule) rule;
if (context.getContext().getRate() != null
&& context.getContext().getRate().getCurrent() > rateLimitRule.getMaxPerMinute()) {
return PolicyDecision.DENY;
}
return null;
}
}

View File

@ -0,0 +1,33 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.ScopeRule;
import org.springframework.stereotype.Component;
@Component
public class ScopeRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof ScopeRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
ScopeRule scopeRule = (ScopeRule) rule;
boolean hasScope = context.getSubject().getScopes().contains(scopeRule.getRequiredScope());
if (!hasScope) {
return null;
}
if (scopeRule.getActions() != null && !scopeRule.getActions().isEmpty()) {
if (!scopeRule.getActions().contains(request.action())) {
return null;
}
}
return scopeRule.getDecision();
}
}

View File

@ -0,0 +1,47 @@
package net.bquarkz.ai.gateway.policy.domain.evaluators;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import net.bquarkz.ai.gateway.policy.internal.RuleEvaluator;
import net.bquarkz.ai.gateway.policy.model.TimeWindowRule;
import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@Component
public class TimeWindowRuleEvaluator implements RuleEvaluator {
@Override
public boolean supports(PolicyRule rule) {
return rule instanceof TimeWindowRule;
}
@Override
public PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context) {
TimeWindowRule timeRule = (TimeWindowRule) rule;
TimeWindowRule.TimeWindow window = timeRule.getWindow();
LocalTime start = LocalTime.parse(window.getStart());
LocalTime end = LocalTime.parse(window.getEnd());
ZoneId zone = ZoneId.of(window.getTimezone());
ZonedDateTime now = context.getContext().getTimestamp().atZoneSameInstant(zone);
LocalTime currentTime = now.toLocalTime();
boolean insideWindow;
if (start.isBefore(end)) {
insideWindow = !currentTime.isBefore(start) && currentTime.isBefore(end);
} else {
insideWindow = !currentTime.isBefore(start) || currentTime.isBefore(end);
}
if (!insideWindow) {
return PolicyDecision.DENY;
}
return null;
}
}

View File

@ -0,0 +1,4 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":gateway-module-policy:policy-contract-subjects"))
}

View File

@ -0,0 +1,17 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.util.List;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ArgumentAllowlistRule extends PolicyRule {
private String argument;
private List<String> allowlist;
}

View File

@ -0,0 +1,14 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class BlockRule extends PolicyRule {
}

View File

@ -0,0 +1,17 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.util.Map;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class EnvironmentRule extends PolicyRule {
private Map<String, PolicyDecision> when;
}

View File

@ -0,0 +1,15 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class RateLimitRule extends PolicyRule {
private int maxPerMinute;
}

View File

@ -0,0 +1,17 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.util.List;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ScopeRule extends PolicyRule {
private String requiredScope;
private List<String> actions;
}

View File

@ -0,0 +1,22 @@
package net.bquarkz.ai.gateway.policy.model;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class TimeWindowRule extends PolicyRule {
private TimeWindow window;
@Data
public static class TimeWindow {
private String start;
private String end;
private String timezone;
}
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":main-contract-subjects"))
api(project(":gateway-module-policy:policy-contract-subjects"))
}

View File

@ -0,0 +1,11 @@
package net.bquarkz.ai.gateway.policy.internal;
import net.bquarkz.ai.gateway.contracts.GatewayContext;
import net.bquarkz.ai.gateway.contracts.PolicyDecision;
import net.bquarkz.ai.gateway.contracts.ToolExecutionRequest;
import net.bquarkz.ai.gateway.policy.contracts.PolicyRule;
public interface RuleEvaluator {
boolean supports(PolicyRule rule);
PolicyDecision evaluate(PolicyRule rule, ToolExecutionRequest request, GatewayContext context);
}

View File

@ -0,0 +1,4 @@
plugins { id("java-library-conventions") }
dependencies {
api(project(":gateway-module-tool-registry:tool-registry-contract-subjects"))
}

View File

@ -0,0 +1,11 @@
package net.bquarkz.ai.gateway.tools.api;
import net.bquarkz.ai.gateway.tools.contracts.ToolDefinition;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import java.util.List;
import java.util.Optional;
public interface ToolRegistry {
Optional<ToolProvider> findTool(String name);
List<ToolDefinition> listTools();
}

View File

@ -0,0 +1,2 @@
plugins { id("java-library-conventions") }
dependencies { implementation(project(":main-infrastructure")) }

View File

@ -0,0 +1,15 @@
package net.bquarkz.ai.gateway.tools.contracts;
import lombok.Builder;
import lombok.Value;
import java.util.List;
import java.util.Map;
@Value
@Builder
public class ToolDefinition {
String name;
String description;
List<String> actions;
Map<String, Object> inputSchema;
}

View File

@ -0,0 +1,12 @@
package net.bquarkz.ai.gateway.tools.contracts;
import java.util.List;
import java.util.Map;
public interface ToolProvider {
String getName();
String getDescription();
List<String> getActions();
Map<String, Object> getInputSchema();
Map<String, Object> execute(String action, Map<String, Object> arguments);
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-tool-registry:tool-registry-api"))
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,39 @@
package net.bquarkz.ai.gateway.tools.domain;
import net.bquarkz.ai.gateway.tools.api.ToolRegistry;
import net.bquarkz.ai.gateway.tools.contracts.ToolDefinition;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ToolRegistryImpl implements ToolRegistry {
private final Map<String, ToolProvider> tools;
public ToolRegistryImpl(List<ToolProvider> providers) {
this.tools = providers.stream()
.collect(Collectors.toMap(ToolProvider::getName, Function.identity()));
}
@Override
public Optional<ToolProvider> findTool(String name) {
return Optional.ofNullable(tools.get(name));
}
@Override
public List<ToolDefinition> listTools() {
return tools.values().stream()
.map(p -> ToolDefinition.builder()
.name(p.getName())
.description(p.getDescription())
.actions(p.getActions())
.inputSchema(p.getInputSchema())
.build())
.toList();
}
}

View File

@ -0,0 +1,5 @@
plugins { id("java-library-conventions") }
dependencies {
implementation(project(":gateway-module-tool-registry:tool-registry-contract-subjects"))
implementation("org.springframework.boot:spring-boot-starter")
}

View File

@ -0,0 +1,35 @@
package net.bquarkz.ai.gateway.providers.database;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class DatabaseExecuteScriptProvider implements ToolProvider {
@Override
public String getName() { return "db.executeScript"; }
@Override
public String getDescription() { return "Execute an arbitrary database script"; }
@Override
public List<String> getActions() { return List.of("execute"); }
@Override
public Map<String, Object> getInputSchema() {
return Map.of(
"type", "object",
"properties", Map.of(
"script", Map.of("type", "string", "description", "SQL script to execute")
),
"required", List.of("script")
);
}
@Override
public Map<String, Object> execute(String action, Map<String, Object> arguments) {
return Map.of("status", "executed");
}
}

View File

@ -0,0 +1,38 @@
package net.bquarkz.ai.gateway.providers.database;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class DatabaseQueryReadonlyProvider implements ToolProvider {
@Override
public String getName() { return "db.queryReadonly"; }
@Override
public String getDescription() { return "Execute a read-only database query"; }
@Override
public List<String> getActions() { return List.of("query"); }
@Override
public Map<String, Object> getInputSchema() {
return Map.of(
"type", "object",
"properties", Map.of(
"sql", Map.of("type", "string", "description", "SQL query to execute")
),
"required", List.of("sql")
);
}
@Override
public Map<String, Object> execute(String action, Map<String, Object> arguments) {
return Map.of(
"rows", List.of(Map.of("id", 1, "name", "test")),
"count", 1
);
}
}

View File

@ -0,0 +1,38 @@
package net.bquarkz.ai.gateway.providers.database;
import net.bquarkz.ai.gateway.tools.contracts.ToolProvider;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class DatabaseRunLiquibaseProvider implements ToolProvider {
@Override
public String getName() { return "db.runLiquibase"; }
@Override
public String getDescription() { return "Run a Liquibase changelog migration"; }
@Override
public List<String> getActions() { return List.of("migrate"); }
@Override
public Map<String, Object> getInputSchema() {
return Map.of(
"type", "object",
"properties", Map.of(
"changelog", Map.of("type", "string", "description", "Changelog file to apply")
),
"required", List.of("changelog")
);
}
@Override
public Map<String, Object> execute(String action, Map<String, Object> arguments) {
return Map.of(
"status", "migration applied",
"changelog", arguments.getOrDefault("changelog", "unknown")
);
}
}

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