first commit
This commit is contained in:
commit
d48172539a
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.gradle
|
||||
build
|
||||
buildSrc/build
|
||||
.idea
|
||||
*.iml
|
||||
.git
|
||||
.claude
|
||||
audit.ndjson
|
||||
plugins/
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
99
ARCHITECTURAL-FACTS.md
Normal 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
6
Dockerfile
Normal 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
125
README.md
Normal 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
|
||||
```
|
||||
16
adapter-http-gateway/build.gradle.kts
Normal file
16
adapter-http-gateway/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
16
adapter-http-security-infra/build.gradle.kts
Normal file
16
adapter-http-security-infra/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
34
app-monolith/build.gradle.kts
Normal file
34
app-monolith/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
10
app-monolith/src/main/resources/application.yml
Normal file
10
app-monolith/src/main/resources/application.yml
Normal 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
3
build.gradle.kts
Normal file
@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("java-common-conventions")
|
||||
}
|
||||
13
buildSrc/build.gradle.kts
Normal file
13
buildSrc/build.gradle.kts
Normal 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")
|
||||
}
|
||||
14
buildSrc/src/main/kotlin/Dependencies.kt
Normal file
14
buildSrc/src/main/kotlin/Dependencies.kt
Normal 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}"
|
||||
}
|
||||
11
buildSrc/src/main/kotlin/Versions.kt
Normal file
11
buildSrc/src/main/kotlin/Versions.kt
Normal 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"
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("java-library-conventions")
|
||||
application
|
||||
}
|
||||
48
buildSrc/src/main/kotlin/java-common-conventions.gradle.kts
Normal file
48
buildSrc/src/main/kotlin/java-common-conventions.gradle.kts
Normal 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
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("java-common-conventions")
|
||||
`java-library`
|
||||
}
|
||||
11
buildSrc/src/main/kotlin/java-test-conventions.gradle.kts
Normal file
11
buildSrc/src/main/kotlin/java-test-conventions.gradle.kts
Normal 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
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
||||
services:
|
||||
gateway:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./audit.ndjson:/app/audit.ndjson
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=local
|
||||
114
files/config/policies-opa.rego
Normal file
114
files/config/policies-opa.rego
Normal 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)
|
||||
}
|
||||
73
files/config/policies.yaml
Normal file
73
files/config/policies.yaml
Normal 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"
|
||||
4
gateway-module-audit/audit-api/build.gradle.kts
Normal file
4
gateway-module-audit/audit-api/build.gradle.kts
Normal file
@ -0,0 +1,4 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":gateway-module-audit:audit-contract-subjects"))
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-infrastructure"))
|
||||
api(project(":main-contract-subjects"))
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
7
gateway-module-audit/audit-domain/build.gradle.kts
Normal file
7
gateway-module-audit/audit-domain/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-infrastructure"))
|
||||
api(project(":gateway-module-authorization:authorization-contract-subjects"))
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies { implementation(project(":main-infrastructure")) }
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies { implementation(project(":gateway-module-authorization:authorization-contract-subjects")) }
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":gateway-module-authorization:authorization-contract-subjects"))
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-contract-subjects"))
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
5
gateway-module-policy/policy-api/build.gradle.kts
Normal file
5
gateway-module-policy/policy-api/build.gradle.kts
Normal file
@ -0,0 +1,5 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-contract-subjects"))
|
||||
api(project(":gateway-module-policy:policy-contract-subjects"))
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-infrastructure"))
|
||||
api(project(":main-contract-subjects"))
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
8
gateway-module-policy/policy-domain/build.gradle.kts
Normal file
8
gateway-module-policy/policy-domain/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":gateway-module-policy:policy-contract-subjects"))
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
5
gateway-module-policy/policy-shared/build.gradle.kts
Normal file
5
gateway-module-policy/policy-shared/build.gradle.kts
Normal file
@ -0,0 +1,5 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":main-contract-subjects"))
|
||||
api(project(":gateway-module-policy:policy-contract-subjects"))
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies {
|
||||
api(project(":gateway-module-tool-registry:tool-registry-contract-subjects"))
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
plugins { id("java-library-conventions") }
|
||||
dependencies { implementation(project(":main-infrastructure")) }
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
5
gateway-providers/database-provider/build.gradle.kts
Normal file
5
gateway-providers/database-provider/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user