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