release: Phase 2 — Production-Ready AgentIdP
Merges all 8 Phase 2 workstreams from develop into main. Workstreams delivered: - WS1: HashiCorp Vault credential storage - WS2: Python SDK (sentryagent-idp) - WS3: Go SDK (github.com/sentryagent/idp-sdk-go) - WS4: Java SDK (ai.sentryagent:idp-sdk) - WS5: OPA Policy Engine (hot-reloadable authz, Rego + Wasm) - WS6: Web Dashboard UI (React 18 + Vite 5, 6 pages) - WS7: Prometheus + Grafana Monitoring (7 metrics, auto-provisioned dashboard) - WS8: Multi-Region Terraform Deployment (AWS ECS/RDS/ElastiCache + GCP Cloud Run/SQL/Memorystore) Quality gates: 344/344 unit tests passing, 96.71% coverage, TypeScript strict throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
95
dashboard/README.md
Normal file
95
dashboard/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# SentryAgent.ai AgentIdP — Web Dashboard
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AgentIdP Dashboard is a React 18 single-page application (SPA) that provides a visual
|
||||
management interface for the AgentIdP API. It allows operators to:
|
||||
|
||||
- Browse, search, and filter all registered AI agents
|
||||
- View agent details and manage lifecycle (suspend / reactivate)
|
||||
- Generate, rotate, and revoke agent credentials
|
||||
- Query the audit log with filters for agent, action, outcome, and date range
|
||||
- Monitor PostgreSQL and Redis connectivity in real time
|
||||
|
||||
The dashboard is co-served by the Express API server at `/dashboard/` — no separate hosting
|
||||
is required.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A running AgentIdP server (local or remote)
|
||||
- An active agent credential (Client ID + Client Secret) with full scopes
|
||||
|
||||
## 3. Development
|
||||
|
||||
Install dashboard dependencies:
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the Vite dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server starts at `http://localhost:5173/dashboard/`. API calls are made to
|
||||
`window.location.origin` (defaulted in the Login form), so either:
|
||||
|
||||
- Set the **API Base URL** field to your local server (e.g. `http://localhost:3000`)
|
||||
- Or configure a Vite proxy in `vite.config.ts` for `/api` and `/health` paths
|
||||
|
||||
## 4. Building
|
||||
|
||||
Compile TypeScript and bundle with Vite:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output is written to `dashboard/dist/`. The build is an optimised static bundle (HTML, CSS, JS).
|
||||
|
||||
To verify the build locally:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 5. Deployment
|
||||
|
||||
The AgentIdP Express server automatically serves the built dashboard:
|
||||
|
||||
- Static assets at `/dashboard/` (via `express.static`)
|
||||
- SPA fallback — all `/dashboard/*` requests not matching a static file return `index.html`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Build the dashboard: `cd dashboard && npm run build`
|
||||
2. Start (or restart) the AgentIdP server: `npm start`
|
||||
3. Open `https://your-api-host/dashboard/` in a browser
|
||||
|
||||
No additional nginx or CDN configuration is required for basic deployments.
|
||||
|
||||
## 6. Login
|
||||
|
||||
The login form has three fields:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| **API Base URL** | Base URL of the AgentIdP server, e.g. `https://api.example.com`. Defaults to the current page origin, which works when the dashboard is co-served. |
|
||||
| **Client ID** | The UUID of an agent registered in AgentIdP. This agent must have the scopes `agents:read agents:write tokens:read audit:read`. |
|
||||
| **Client Secret** | The plain-text client secret for the agent. Validated against the token endpoint on login. |
|
||||
|
||||
Credentials are stored in `sessionStorage` only — they are cleared when the browser tab is closed.
|
||||
|
||||
## 7. Pages
|
||||
|
||||
| Page | Route | Description |
|
||||
|---|---|---|
|
||||
| **Agents** | `/dashboard/agents` | Paginated list of all agents. Search by email (debounced), filter by status. Click a row for details. |
|
||||
| **Agent Detail** | `/dashboard/agents/:agentId` | Full agent metadata. Suspend or reactivate (with confirmation). Link to credentials. |
|
||||
| **Credentials** | `/dashboard/agents/:agentId/credentials` | List all credentials. Generate, rotate, or revoke. New secrets shown exactly once. |
|
||||
| **Audit Log** | `/dashboard/audit` | Paginated audit events with filters for agent ID, action, outcome, and date range. |
|
||||
| **Health** | `/dashboard/health` | PostgreSQL and Redis connectivity cards. Auto-refreshes every 30 seconds. |
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SentryAgent.ai — AgentIdP Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2755
dashboard/package-lock.json
generated
Normal file
2755
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
dashboard/package.json
Normal file
29
dashboard/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@sentryagent/dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.app.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentryagent/idp-sdk": "file:../sdk",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"lucide-react": "^0.446.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
33
dashboard/src/App.tsx
Normal file
33
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { RequireAuth } from '@/components/RequireAuth';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import Login from '@/pages/Login';
|
||||
import Agents from '@/pages/Agents';
|
||||
import AgentDetail from '@/pages/AgentDetail';
|
||||
import Credentials from '@/pages/Credentials';
|
||||
import AuditLog from '@/pages/AuditLog';
|
||||
import Health from '@/pages/Health';
|
||||
|
||||
/** Top-level router — defines all application routes. */
|
||||
export default function App(): React.JSX.Element {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard/login" element={<Login />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/dashboard/agents" element={<Agents />} />
|
||||
<Route path="/dashboard/agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="/dashboard/agents/:agentId/credentials" element={<Credentials />} />
|
||||
<Route path="/dashboard/audit" element={<AuditLog />} />
|
||||
<Route path="/dashboard/health" element={<Health />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/agents" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/agents" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
11
dashboard/src/components/RequireAuth.tsx
Normal file
11
dashboard/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
|
||||
/** Redirects to /dashboard/login if not authenticated. */
|
||||
export function RequireAuth(): React.JSX.Element {
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/dashboard/login" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
62
dashboard/src/components/layout/AppShell.tsx
Normal file
62
dashboard/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard/agents', label: 'Agents' },
|
||||
{ to: '/dashboard/audit', label: 'Audit Log' },
|
||||
{ to: '/dashboard/health', label: 'Health' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Outer application shell: top navigation bar and main content area.
|
||||
* Renders the active page via <Outlet />.
|
||||
*/
|
||||
export function AppShell(): React.JSX.Element {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="text-lg font-bold text-brand-700">SentryAgent.ai</span>
|
||||
<nav className="flex gap-1">
|
||||
{NAV_ITEMS.map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/ui/badge.tsx
Normal file
27
dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'muted';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-brand-100 text-brand-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
danger: 'bg-red-100 text-red-700',
|
||||
muted: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
/** Small status badge. */
|
||||
export function Badge({ variant = 'default', children, className }: BadgeProps): React.JSX.Element {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', variantClasses[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
65
dashboard/src/components/ui/button.tsx
Normal file
65
dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant = 'default' | 'destructive' | 'outline' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: 'bg-brand-600 text-white hover:bg-brand-700 focus:ring-brand-500',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus:ring-brand-500',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-brand-500',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable button component with variant and size support.
|
||||
*
|
||||
* @param variant - Visual style: default | destructive | outline | ghost
|
||||
* @param size - Size: sm | md | lg
|
||||
* @param loading - When true, shows a spinner and disables the button
|
||||
*/
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md font-medium',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled ?? loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
45
dashboard/src/components/ui/dialog.tsx
Normal file
45
dashboard/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from './button';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal confirmation dialog for destructive actions (suspend, revoke, rotate).
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps): React.JSX.Element | null {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">{description}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
dashboard/src/index.css
Normal file
26
dashboard/src/index.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 198 89% 48%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
109
dashboard/src/lib/auth.tsx
Normal file
109
dashboard/src/lib/auth.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { TokenManager } from '@sentryagent/idp-sdk';
|
||||
|
||||
const SESSION_KEY = 'agentidp_credentials';
|
||||
|
||||
interface StoredCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists user credentials to sessionStorage (cleared on tab close).
|
||||
*/
|
||||
export function saveCredentials(creds: StoredCredentials): void {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(creds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves credentials from sessionStorage.
|
||||
* Returns null if not logged in.
|
||||
*/
|
||||
export function loadCredentials(): StoredCredentials | null {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes credentials from sessionStorage (logout).
|
||||
*/
|
||||
export function clearCredentials(): void {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user has stored credentials.
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return loadCredentials() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates stored credentials by requesting a token.
|
||||
* Returns true if successful; false on auth failure.
|
||||
*/
|
||||
export async function validateCredentials(creds: StoredCredentials): Promise<boolean> {
|
||||
try {
|
||||
const tm = new TokenManager(creds.baseUrl, creds.clientId, creds.clientSecret, 'agents:read agents:write tokens:read audit:read');
|
||||
await tm.getToken();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── React context ──────────────────────────────────────────────────────────────
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface AuthContextValue {
|
||||
credentials: StoredCredentials | null;
|
||||
login: (creds: StoredCredentials) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides authentication state to the application.
|
||||
* Reads initial state from sessionStorage on mount.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const [credentials, setCredentials] = React.useState<StoredCredentials | null>(loadCredentials);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const login = React.useCallback(async (creds: StoredCredentials): Promise<boolean> => {
|
||||
const valid = await validateCredentials(creds);
|
||||
if (valid) {
|
||||
saveCredentials(creds);
|
||||
setCredentials(creds);
|
||||
}
|
||||
return valid;
|
||||
}, []);
|
||||
|
||||
const logout = React.useCallback((): void => {
|
||||
clearCredentials();
|
||||
setCredentials(null);
|
||||
navigate('/dashboard/login');
|
||||
}, [navigate]);
|
||||
|
||||
const value = React.useMemo(() => ({ credentials, login, logout }), [credentials, login, logout]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication context.
|
||||
* Must be used inside <AuthProvider>.
|
||||
*/
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = React.useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
18
dashboard/src/lib/client.ts
Normal file
18
dashboard/src/lib/client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AgentIdPClient } from '@sentryagent/idp-sdk';
|
||||
import { loadCredentials } from './auth';
|
||||
|
||||
/**
|
||||
* Returns an AgentIdPClient configured with credentials from sessionStorage.
|
||||
* Throws if not authenticated (caller must ensure login first).
|
||||
*/
|
||||
export function getClient(): AgentIdPClient {
|
||||
const creds = loadCredentials();
|
||||
if (!creds) {
|
||||
throw new Error('Not authenticated. Please log in.');
|
||||
}
|
||||
return new AgentIdPClient({
|
||||
baseUrl: creds.baseUrl,
|
||||
clientId: creds.clientId,
|
||||
clientSecret: creds.clientSecret,
|
||||
});
|
||||
}
|
||||
7
dashboard/src/lib/utils.ts
Normal file
7
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merges Tailwind class names, handling conflicts correctly. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
dashboard/src/main.tsx
Normal file
13
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
222
dashboard/src/pages/AgentDetail.tsx
Normal file
222
dashboard/src/pages/AgentDetail.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Agent } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'danger';
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: Agent['status']): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Single label/value row in the detail card. */
|
||||
function DetailRow({ label, value }: DetailRowProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
|
||||
<dt className="w-36 shrink-0 text-sm font-medium text-slate-500">{label}</dt>
|
||||
<dd className="text-sm text-slate-900 break-all">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogAction = 'suspend' | 'reactivate';
|
||||
|
||||
/**
|
||||
* Agent Detail page — shows all agent fields and provides suspend/reactivate actions.
|
||||
* Route: /dashboard/agents/:agentId
|
||||
*/
|
||||
export default function AgentDetail(): React.JSX.Element {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agent, setAgent] = React.useState<Agent | null>(null);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
|
||||
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgent = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().agents.getAgent(agentId);
|
||||
if (!cancelled) setAgent(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load agent.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgent();
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId]);
|
||||
|
||||
const handleAction = React.useCallback(
|
||||
async (action: DialogAction): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setActionLoading(true);
|
||||
setDialog(null);
|
||||
try {
|
||||
const newStatus = action === 'suspend' ? 'suspended' : 'active';
|
||||
const updated = await getClient().agents.updateAgent(agentId, { status: newStatus });
|
||||
setAgent(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Action failed.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-5 w-full animate-pulse rounded bg-slate-200" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error ?? 'Agent not found.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dialogConfig = dialog === 'suspend'
|
||||
? {
|
||||
title: `Suspend agent ${agent.email}?`,
|
||||
description: `Suspending ${agent.email} means it will no longer be able to authenticate.`,
|
||||
confirmLabel: 'Suspend',
|
||||
variant: 'destructive' as const,
|
||||
}
|
||||
: {
|
||||
title: `Reactivate agent ${agent.email}?`,
|
||||
description: `Reactivating ${agent.email} will allow it to authenticate again.`,
|
||||
confirmLabel: 'Reactivate',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={() => { navigate('/dashboard/agents'); }}
|
||||
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{agent.email}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Agent ID: {agent.agentId}</p>
|
||||
</div>
|
||||
<Badge variant={statusVariant(agent.status)} className="mt-1">{agent.status}</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail card */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Email" value={agent.email} />
|
||||
<DetailRow label="Agent ID" value={agent.agentId} />
|
||||
<DetailRow label="Type" value={agent.agentType} />
|
||||
<DetailRow label="Version" value={agent.version} />
|
||||
<DetailRow label="Owner" value={agent.owner} />
|
||||
<DetailRow label="Environment" value={agent.deploymentEnv} />
|
||||
<DetailRow label="Capabilities" value={agent.capabilities.join(', ') || '—'} />
|
||||
<DetailRow label="Status" value={agent.status} />
|
||||
<DetailRow label="Created" value={formatDateTime(agent.createdAt)} />
|
||||
<DetailRow label="Updated" value={formatDateTime(agent.updatedAt)} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{agent.status !== 'decommissioned' && (
|
||||
<div className="mt-6 flex gap-3">
|
||||
{agent.status === 'active' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('suspend'); }}
|
||||
>
|
||||
Suspend Agent
|
||||
</Button>
|
||||
)}
|
||||
{agent.status === 'suspended' && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('reactivate'); }}
|
||||
>
|
||||
Reactivate Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials section */}
|
||||
<div className="mt-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">Credentials</h2>
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Manage client secrets for this agent. Rotate or revoke credentials as needed.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}/credentials`); }}
|
||||
>
|
||||
View Credentials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{dialog !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={dialogConfig.title}
|
||||
description={dialogConfig.description}
|
||||
confirmLabel={dialogConfig.confirmLabel}
|
||||
variant={dialogConfig.variant}
|
||||
onConfirm={() => { void handleAction(dialog); }}
|
||||
onCancel={() => { setDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
dashboard/src/pages/Agents.tsx
Normal file
204
dashboard/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Agent, AgentStatus } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: AgentStatus): 'success' | 'warning' | 'danger' | 'muted' {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a short local date string. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** Skeleton row shown while loading. */
|
||||
function SkeletonRow(): React.JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents list page — displays all registered agents with search, status filter, and pagination.
|
||||
* Clicking a row navigates to the Agent Detail page.
|
||||
*/
|
||||
export default function Agents(): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters (client-side email search, server-side status)
|
||||
const [searchInput, setSearchInput] = React.useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<AgentStatus | ''>('');
|
||||
|
||||
// Debounce search input 300ms
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => { setDebouncedSearch(searchInput); }, 300);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [searchInput]);
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
try {
|
||||
const client = getClient();
|
||||
const result = await client.agents.listAgents({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
status: statusFilter !== '' ? statusFilter : undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAgents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load agents.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, statusFilter]);
|
||||
|
||||
// Client-side email filter applied after API results arrive
|
||||
const filteredAgents = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return agents;
|
||||
const lower = debouncedSearch.toLowerCase();
|
||||
return agents.filter((a) => a.email.toLowerCase().includes(lower));
|
||||
}, [agents, debouncedSearch]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Agents</h1>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => { setSearchInput(e.target.value); }}
|
||||
placeholder="Search by email…"
|
||||
className="w-60 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value as AgentStatus | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="decommissioned">Decommissioned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Name (Email)', 'Type', 'Status', 'Environment', 'Owner', 'Created'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
|
||||
: filteredAgents.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
|
||||
No agents found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filteredAgents.map((agent) => (
|
||||
<tr
|
||||
key={agent.agentId}
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}`); }}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-brand-700">{agent.email}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.agentType}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={statusVariant(agent.status)}>{agent.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.deploymentEnv}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.owner}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(agent.createdAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
dashboard/src/pages/AuditLog.tsx
Normal file
223
dashboard/src/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react';
|
||||
import type { AuditEvent, AuditAction, AuditOutcome } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** All AuditAction values for the filter dropdown. */
|
||||
const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'agent.created',
|
||||
'agent.updated',
|
||||
'agent.decommissioned',
|
||||
'agent.suspended',
|
||||
'agent.reactivated',
|
||||
'token.issued',
|
||||
'token.revoked',
|
||||
'token.introspected',
|
||||
'credential.generated',
|
||||
'credential.rotated',
|
||||
'credential.revoked',
|
||||
'auth.failed',
|
||||
];
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Truncates a string to a maximum length with ellipsis. */
|
||||
function truncate(value: string, maxLen = 24): string {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit Log page — displays audit events with filters for agent, action, outcome, and date range.
|
||||
* Route: /dashboard/audit
|
||||
*/
|
||||
export default function AuditLog(): React.JSX.Element {
|
||||
const [events, setEvents] = React.useState<AuditEvent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [agentIdFilter, setAgentIdFilter] = React.useState<string>('');
|
||||
const [actionFilter, setActionFilter] = React.useState<AuditAction | ''>('');
|
||||
const [outcomeFilter, setOutcomeFilter] = React.useState<AuditOutcome | ''>('');
|
||||
const [fromDate, setFromDate] = React.useState<string>('');
|
||||
const [toDate, setToDate] = React.useState<string>('');
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchEvents = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().audit.queryAuditLog({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
agentId: agentIdFilter.trim() || undefined,
|
||||
action: actionFilter !== '' ? actionFilter : undefined,
|
||||
outcome: outcomeFilter !== '' ? outcomeFilter : undefined,
|
||||
fromDate: fromDate || undefined,
|
||||
toDate: toDate || undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setEvents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit log.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchEvents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold text-slate-900">Audit Log</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<input
|
||||
type="text"
|
||||
value={agentIdFilter}
|
||||
onChange={(e) => { setAgentIdFilter(e.target.value); }}
|
||||
placeholder="Agent ID…"
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value as AuditAction | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{AUDIT_ACTIONS.map((action) => (
|
||||
<option key={action} value={action}>{action}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={outcomeFilter}
|
||||
onChange={(e) => { setOutcomeFilter(e.target.value as AuditOutcome | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Outcomes</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failure">Failure</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => { setFromDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => { setToDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="To date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Timestamp', 'Agent ID', 'Action', 'Outcome', 'IP Address'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 5 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: events.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
|
||||
No audit events found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: events.map((event) => (
|
||||
<tr key={event.eventId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDateTime(event.timestamp)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-slate-700">{truncate(event.agentId)}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{event.action}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{event.ipAddress}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
dashboard/src/pages/Credentials.tsx
Normal file
264
dashboard/src/pages/Credentials.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Credential, CredentialWithSecret } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
/** Truncates a string to a maximum length with ellipsis. */
|
||||
function truncate(value: string, maxLen = 16): string {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a short local date string. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface NewSecretBoxProps {
|
||||
secret: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a newly issued client secret exactly once.
|
||||
* Provides a copy button and a dismiss button.
|
||||
*/
|
||||
function NewSecretBox({ secret, onDismiss }: NewSecretBoxProps): React.JSX.Element {
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
|
||||
const handleCopy = React.useCallback(async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
}, [secret]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border-2 border-green-400 bg-green-50 p-4">
|
||||
<p className="mb-2 text-sm font-semibold text-green-800">
|
||||
New client secret — copy it now. It will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 break-all rounded bg-white px-3 py-2 text-sm font-mono text-green-900 border border-green-200">
|
||||
{secret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => { void handleCopy(); }}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="mt-3 text-xs text-green-700 underline hover:text-green-900"
|
||||
>
|
||||
I have saved this secret — dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogAction = { type: 'rotate'; credentialId: string } | { type: 'revoke'; credentialId: string };
|
||||
|
||||
/**
|
||||
* Credentials page — lists all credentials for an agent with rotate/revoke actions.
|
||||
* Route: /dashboard/agents/:agentId/credentials
|
||||
*/
|
||||
export default function Credentials(): React.JSX.Element {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [credentials, setCredentials] = React.useState<Credential[]>([]);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
|
||||
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
|
||||
const [newSecret, setNewSecret] = React.useState<CredentialWithSecret | null>(null);
|
||||
|
||||
const fetchCredentials = React.useCallback(async (): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getClient().credentials.listCredentials(agentId);
|
||||
setCredentials(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const handleGenerate = React.useCallback(async (): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setActionLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getClient().credentials.generateCredential(agentId, {});
|
||||
setNewSecret(result);
|
||||
await fetchCredentials();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate credential.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [agentId, fetchCredentials]);
|
||||
|
||||
const handleConfirm = React.useCallback(async (): Promise<void> => {
|
||||
if (!dialog || !agentId) return;
|
||||
setActionLoading(true);
|
||||
setDialog(null);
|
||||
setError(null);
|
||||
try {
|
||||
if (dialog.type === 'rotate') {
|
||||
const result = await getClient().credentials.rotateCredential(agentId, dialog.credentialId);
|
||||
setNewSecret(result);
|
||||
} else {
|
||||
await getClient().credentials.revokeCredential(agentId, dialog.credentialId);
|
||||
}
|
||||
await fetchCredentials();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${dialog.type} credential.`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [dialog, agentId, fetchCredentials]);
|
||||
|
||||
const dialogConfig = React.useMemo(() => {
|
||||
if (!dialog) return null;
|
||||
if (dialog.type === 'rotate') {
|
||||
return {
|
||||
title: 'Rotate credential?',
|
||||
description: 'The existing secret will be invalidated immediately. You will receive a new secret — store it securely.',
|
||||
confirmLabel: 'Rotate',
|
||||
variant: 'destructive' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Revoke credential?',
|
||||
description: 'This will permanently revoke the credential. This cannot be undone.',
|
||||
confirmLabel: 'Revoke',
|
||||
variant: 'destructive' as const,
|
||||
};
|
||||
}, [dialog]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={() => { navigate(`/dashboard/agents/${agentId ?? ''}`); }}
|
||||
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
← Back to Agent
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Credentials</h1>
|
||||
<Button
|
||||
loading={actionLoading}
|
||||
onClick={() => { void handleGenerate(); }}
|
||||
>
|
||||
Generate Credential
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New secret display — shown once */}
|
||||
{newSecret !== null && (
|
||||
<NewSecretBox
|
||||
secret={newSecret.clientSecret}
|
||||
onDismiss={() => { setNewSecret(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Credentials table */}
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Credential ID', 'Status', 'Created', 'Actions'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 4 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : credentials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-slate-400">
|
||||
No credentials found. Generate one above.
|
||||
</td>
|
||||
</tr>
|
||||
) : credentials.map((cred) => (
|
||||
<tr key={cred.credentialId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-slate-700">
|
||||
{truncate(cred.credentialId, 24)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={cred.status === 'active' ? 'success' : 'muted'}>
|
||||
{cred.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(cred.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{cred.status === 'active' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => { setDialog({ type: 'rotate', credentialId: cred.credentialId }); }}
|
||||
>
|
||||
Rotate
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => { setDialog({ type: 'revoke', credentialId: cred.credentialId }); }}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{dialog !== null && dialogConfig !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={dialogConfig.title}
|
||||
description={dialogConfig.description}
|
||||
confirmLabel={dialogConfig.confirmLabel}
|
||||
variant={dialogConfig.variant}
|
||||
onConfirm={() => { void handleConfirm(); }}
|
||||
onCancel={() => { setDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
dashboard/src/pages/Health.tsx
Normal file
173
dashboard/src/pages/Health.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/** Shape of the /health API response. */
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded';
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
services: {
|
||||
postgres: 'connected' | 'disconnected';
|
||||
redis: 'connected' | 'disconnected';
|
||||
};
|
||||
}
|
||||
|
||||
type ServiceStatus = 'connected' | 'disconnected' | 'unknown';
|
||||
|
||||
interface HealthState {
|
||||
postgres: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
version: string | null;
|
||||
uptime: number | null;
|
||||
lastChecked: Date | null;
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
const initialState: HealthState = {
|
||||
postgres: 'unknown',
|
||||
redis: 'unknown',
|
||||
version: null,
|
||||
uptime: null,
|
||||
lastChecked: null,
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
/** Formats seconds into a human-readable uptime string. */
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
parts.push(`${minutes}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
}
|
||||
|
||||
/** Card displaying the connectivity status of a single service. */
|
||||
function StatusCard({ label, status }: StatusCardProps): React.JSX.Element {
|
||||
const isConnected = status === 'connected';
|
||||
const isUnknown = status === 'unknown';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-6 shadow-sm ${
|
||||
isUnknown
|
||||
? 'border-slate-200 bg-slate-50'
|
||||
: isConnected
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<p className="text-sm font-medium text-slate-600">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`inline-block h-3 w-3 rounded-full ${
|
||||
isUnknown ? 'bg-slate-400' : isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className={`text-lg font-semibold ${
|
||||
isUnknown ? 'text-slate-600' : isConnected ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{isUnknown ? 'Checking…' : isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health page — shows PostgreSQL and Redis connectivity status.
|
||||
* Polls GET /health every 30 seconds. No authentication required.
|
||||
* Route: /dashboard/health
|
||||
*/
|
||||
export default function Health(): React.JSX.Element {
|
||||
const [health, setHealth] = React.useState<HealthState>(initialState);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
|
||||
const checkHealth = React.useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = (await response.json()) as HealthResponse;
|
||||
|
||||
setHealth({
|
||||
postgres: data.services?.postgres ?? 'unknown',
|
||||
redis: data.services?.redis ?? 'unknown',
|
||||
version: data.version ?? null,
|
||||
uptime: data.uptime ?? null,
|
||||
lastChecked: new Date(),
|
||||
reachable: true,
|
||||
});
|
||||
} catch {
|
||||
setHealth((prev) => ({
|
||||
...prev,
|
||||
postgres: 'disconnected',
|
||||
redis: 'disconnected',
|
||||
lastChecked: new Date(),
|
||||
reachable: false,
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => { void checkHealth(); }, 30_000);
|
||||
return () => { clearInterval(interval); };
|
||||
}, [checkHealth]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">System Health</h1>
|
||||
<button
|
||||
onClick={() => { void checkHealth(); }}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!health.reachable && (
|
||||
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
API is unreachable. Check that the server is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatusCard label="PostgreSQL" status={loading ? 'unknown' : health.postgres} />
|
||||
<StatusCard label="Redis" status={loading ? 'unknown' : health.redis} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{(health.version !== null || health.uptime !== null) && (
|
||||
<div className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-base font-semibold text-slate-900">API Details</h2>
|
||||
<dl className="space-y-2">
|
||||
{health.version !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Version</dt>
|
||||
<dd className="text-sm text-slate-900">{health.version}</dd>
|
||||
</div>
|
||||
)}
|
||||
{health.uptime !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Uptime</dt>
|
||||
<dd className="text-sm text-slate-900">{formatUptime(health.uptime)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last checked */}
|
||||
{health.lastChecked !== null && (
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
Last checked: {health.lastChecked.toLocaleTimeString()} — auto-refreshes every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
dashboard/src/pages/Login.tsx
Normal file
109
dashboard/src/pages/Login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Login page — accepts API Base URL, Client ID, and Client Secret.
|
||||
* Validates credentials against the AgentIdP token endpoint before persisting.
|
||||
*/
|
||||
export default function Login(): React.JSX.Element {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [baseUrl, setBaseUrl] = React.useState<string>(window.location.origin);
|
||||
const [clientId, setClientId] = React.useState<string>('');
|
||||
const [clientSecret, setClientSecret] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const success = await login({ baseUrl: baseUrl.trim(), clientId: clientId.trim(), clientSecret });
|
||||
if (success) {
|
||||
navigate('/dashboard/agents', { replace: true });
|
||||
} else {
|
||||
setError('Invalid credentials. Please check your Client ID and secret.');
|
||||
setClientSecret('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[login, navigate, baseUrl, clientId, clientSecret],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-brand-700">SentryAgent.ai</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">AgentIdP Dashboard — Sign In</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { void handleSubmit(e); }} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="baseUrl" className="block text-sm font-medium text-slate-700">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
id="baseUrl"
|
||||
type="url"
|
||||
required
|
||||
value={baseUrl}
|
||||
onChange={(e) => { setBaseUrl(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientId" className="block text-sm font-medium text-slate-700">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
id="clientId"
|
||||
type="text"
|
||||
required
|
||||
value={clientId}
|
||||
onChange={(e) => { setClientId(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="agent-uuid"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientSecret" className="block text-sm font-medium text-slate-700">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
required
|
||||
value={clientSecret}
|
||||
onChange={(e) => { setClientSecret(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
{loading ? 'Validating…' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
19
dashboard/tailwind.config.js
Normal file
19
dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
dashboard/tsconfig.app.json
Normal file
25
dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
20
dashboard/tsconfig.node.json
Normal file
20
dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
dashboard/vite.config.ts
Normal file
17
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/dashboard/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
50
docker-compose.monitoring.yml
Normal file
50
docker-compose.monitoring.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
version: '3.8'
|
||||
|
||||
# Monitoring overlay — extend the base docker-compose.yml
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.53.0
|
||||
container_name: agentidp_prometheus
|
||||
volumes:
|
||||
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||
- '--web.console.templates=/etc/prometheus/consoles'
|
||||
- '--web.enable-lifecycle'
|
||||
ports:
|
||||
- '9090:9090'
|
||||
networks:
|
||||
- agentidp_network
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.0
|
||||
container_name: agentidp_grafana
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=agentidp
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=false
|
||||
ports:
|
||||
- '3001:3000'
|
||||
networks:
|
||||
- agentidp_network
|
||||
depends_on:
|
||||
- prometheus
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
|
||||
networks:
|
||||
agentidp_network:
|
||||
external: true
|
||||
603
docs/devops/deployment.md
Normal file
603
docs/devops/deployment.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Deployment Guide — SentryAgent.ai AgentIdP
|
||||
|
||||
End-to-end guide for deploying AgentIdP to AWS (primary) and GCP (secondary) using the Terraform infrastructure-as-code in `terraform/`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [AWS Deployment](#2-aws-deployment)
|
||||
3. [GCP Deployment](#3-gcp-deployment)
|
||||
4. [Post-Deploy Verification](#4-post-deploy-verification)
|
||||
5. [Rollback Procedure](#5-rollback-procedure)
|
||||
6. [Environment Variable Reference](#6-environment-variable-reference)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Minimum Version | Install |
|
||||
|------|-----------------|---------|
|
||||
| Terraform | 1.6.0 | https://developer.hashicorp.com/terraform/install |
|
||||
| AWS CLI | 2.13 | https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html |
|
||||
| gcloud CLI | 460.0 | https://cloud.google.com/sdk/docs/install |
|
||||
| Docker | 24.0 | Required only for building and pushing images |
|
||||
| openssl | any | Required for generating JWT key pairs |
|
||||
|
||||
Verify all tools are available:
|
||||
|
||||
```bash
|
||||
terraform version
|
||||
aws --version
|
||||
gcloud version
|
||||
docker version
|
||||
openssl version
|
||||
```
|
||||
|
||||
### Container Image
|
||||
|
||||
Build and push the `sentryagent/agentidp` image to your registry before deploying. Terraform references the image by tag — it does not build it.
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
docker build -t sentryagent/agentidp:1.0.0 .
|
||||
|
||||
# Push to your registry (ECR example):
|
||||
aws ecr get-login-password --region us-east-1 \
|
||||
| docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
|
||||
|
||||
docker tag sentryagent/agentidp:1.0.0 \
|
||||
123456789012.dkr.ecr.us-east-1.amazonaws.com/sentryagent/agentidp:1.0.0
|
||||
|
||||
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/sentryagent/agentidp:1.0.0
|
||||
```
|
||||
|
||||
Update `app_image_tag` in your `terraform.tfvars` to match.
|
||||
|
||||
### JWT Key Pair
|
||||
|
||||
Generate the RSA-2048 key pair used for signing and verifying JWTs:
|
||||
|
||||
```bash
|
||||
openssl genrsa -out jwt_private.pem 2048
|
||||
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
|
||||
|
||||
# Verify
|
||||
openssl rsa -in jwt_private.pem -check -noout
|
||||
```
|
||||
|
||||
Keep `jwt_private.pem` secure — treat it with the same sensitivity as a TLS private key. You will paste its contents into `terraform.tfvars`.
|
||||
|
||||
---
|
||||
|
||||
## 2. AWS Deployment
|
||||
|
||||
### 2.1 Configure AWS CLI
|
||||
|
||||
```bash
|
||||
aws configure
|
||||
# Provide: AWS Access Key ID, Secret Access Key, region (e.g. us-east-1), output format (json)
|
||||
|
||||
# Verify credentials
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
|
||||
The IAM principal running Terraform requires permissions to manage: VPC, ECS, RDS, ElastiCache, ALB, IAM roles, Secrets Manager, Route 53, CloudWatch, and VPC endpoints.
|
||||
|
||||
### 2.2 Provision an ACM Certificate
|
||||
|
||||
The ALB requires an ACM certificate for your domain. Create it in the same region as your deployment.
|
||||
|
||||
```bash
|
||||
aws acm request-certificate \
|
||||
--domain-name idp.example.com \
|
||||
--validation-method DNS \
|
||||
--region us-east-1
|
||||
```
|
||||
|
||||
Complete DNS validation by adding the CNAME record shown in the ACM console. Wait for the status to become `ISSUED` before proceeding.
|
||||
|
||||
```bash
|
||||
# Monitor validation status
|
||||
aws acm describe-certificate \
|
||||
--certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/XXXX \
|
||||
--region us-east-1 \
|
||||
--query 'Certificate.Status'
|
||||
```
|
||||
|
||||
### 2.3 Prepare tfvars
|
||||
|
||||
```bash
|
||||
cd terraform/environments/aws
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
```
|
||||
|
||||
Edit `terraform.tfvars`. All fields marked `REPLACE_WITH_*` are required. Key fields:
|
||||
|
||||
- `region` — AWS region (must match the ACM certificate region)
|
||||
- `domain_name` — your domain (e.g. `idp.example.com`)
|
||||
- `certificate_arn` — ARN from step 2.2
|
||||
- `app_image_tag` — tag of the image you pushed in step 1
|
||||
- `db_password` — strong random password (no `@`, `#`, `?`, `/` characters — they break URL parsing)
|
||||
- `redis_auth_token` — minimum 16 characters, no spaces
|
||||
- `jwt_private_key` — full PEM contents of `jwt_private.pem` with literal `\n` for newlines
|
||||
- `jwt_public_key` — full PEM contents of `jwt_public.pem` with literal `\n` for newlines
|
||||
|
||||
Example for encoding PEM keys in tfvars:
|
||||
|
||||
```bash
|
||||
# Output the private key as a single line with \n separators (for pasting into tfvars)
|
||||
awk 'NF {printf "%s\\n", $0}' jwt_private.pem
|
||||
```
|
||||
|
||||
**Never commit `terraform.tfvars` to version control.**
|
||||
|
||||
### 2.4 Configure Remote State (Recommended)
|
||||
|
||||
Uncomment and configure the `backend "s3"` block in `terraform/environments/aws/main.tf`:
|
||||
|
||||
```hcl
|
||||
backend "s3" {
|
||||
bucket = "your-terraform-state-bucket"
|
||||
key = "agentidp/aws/production/terraform.tfstate"
|
||||
region = "us-east-1"
|
||||
encrypt = true
|
||||
dynamodb_table = "your-terraform-locks-table"
|
||||
}
|
||||
```
|
||||
|
||||
Create the S3 bucket and DynamoDB table if they do not exist:
|
||||
|
||||
```bash
|
||||
# S3 bucket with versioning and encryption
|
||||
aws s3api create-bucket --bucket your-terraform-state-bucket --region us-east-1
|
||||
aws s3api put-bucket-versioning \
|
||||
--bucket your-terraform-state-bucket \
|
||||
--versioning-configuration Status=Enabled
|
||||
aws s3api put-bucket-encryption \
|
||||
--bucket your-terraform-state-bucket \
|
||||
--server-side-encryption-configuration \
|
||||
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
|
||||
|
||||
# DynamoDB table for state locking
|
||||
aws dynamodb create-table \
|
||||
--table-name your-terraform-locks-table \
|
||||
--attribute-definitions AttributeName=LockID,AttributeType=S \
|
||||
--key-schema AttributeName=LockID,KeyType=HASH \
|
||||
--billing-mode PAY_PER_REQUEST \
|
||||
--region us-east-1
|
||||
```
|
||||
|
||||
### 2.5 Terraform Init
|
||||
|
||||
```bash
|
||||
cd terraform/environments/aws
|
||||
terraform init
|
||||
```
|
||||
|
||||
Expected output: provider plugins downloaded, backend initialized.
|
||||
|
||||
### 2.6 Terraform Plan
|
||||
|
||||
```bash
|
||||
terraform plan -out=tfplan
|
||||
```
|
||||
|
||||
Review the plan carefully before applying. Expected resources on first apply: ~50–60 resources (VPC, subnets, NAT gateways, VPC endpoints, IAM roles, secrets, RDS, ElastiCache, ALB, ECS cluster, task definition, service, Route 53 record).
|
||||
|
||||
### 2.7 Terraform Apply
|
||||
|
||||
```bash
|
||||
terraform apply tfplan
|
||||
```
|
||||
|
||||
**First apply takes 20–30 minutes** — RDS Multi-AZ provisioning is the longest step (~15 min). Do not interrupt the apply.
|
||||
|
||||
When complete, note the outputs:
|
||||
|
||||
```bash
|
||||
terraform output
|
||||
```
|
||||
|
||||
Key outputs:
|
||||
- `service_url` — the HTTPS URL of your deployed service
|
||||
- `alb_dns_name` — ALB DNS name (verify Route 53 alias is pointing here)
|
||||
- `ecs_service_name` — use for ECS deployment commands
|
||||
- `cloudwatch_log_group` — where container logs appear
|
||||
|
||||
### 2.8 Run Database Migrations
|
||||
|
||||
After first deploy, run migrations against the new RDS instance. The easiest approach is to exec into a running ECS task:
|
||||
|
||||
```bash
|
||||
# Get a running task ARN
|
||||
TASK_ARN=$(aws ecs list-tasks \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--service-name sentryagent-agentidp-production \
|
||||
--query 'taskArns[0]' \
|
||||
--output text)
|
||||
|
||||
# Run migrations via ECS Exec (requires enableExecuteCommand on the service)
|
||||
aws ecs execute-command \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--task $TASK_ARN \
|
||||
--container agentidp \
|
||||
--command "node scripts/db-migrate.js" \
|
||||
--interactive
|
||||
```
|
||||
|
||||
Alternatively, run a one-off ECS task with the migration command as the container override.
|
||||
|
||||
---
|
||||
|
||||
## 3. GCP Deployment
|
||||
|
||||
### 3.1 Configure gcloud CLI
|
||||
|
||||
```bash
|
||||
gcloud auth login
|
||||
gcloud config set project your-gcp-project-id
|
||||
gcloud auth application-default login
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
gcloud config list
|
||||
gcloud projects describe your-gcp-project-id
|
||||
```
|
||||
|
||||
The principal running Terraform requires the following roles on the project:
|
||||
- `roles/owner` or a custom role covering: Cloud Run Admin, Cloud SQL Admin, Redis Admin, Secret Manager Admin, IAM Admin, Compute Admin, Service Networking Admin.
|
||||
|
||||
### 3.2 Prepare tfvars
|
||||
|
||||
```bash
|
||||
cd terraform/environments/gcp
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
```
|
||||
|
||||
Edit `terraform.tfvars`. Key fields:
|
||||
|
||||
- `project_id` — your GCP project ID
|
||||
- `region` — GCP region (e.g. `us-central1`)
|
||||
- `app_image_tag` — tag of the image you built
|
||||
- `db_password` — strong random password for Cloud SQL
|
||||
- `jwt_private_key` / `jwt_public_key` — same PEM keys used for AWS (same key pair for both regions)
|
||||
|
||||
**Never commit `terraform.tfvars` to version control.**
|
||||
|
||||
### 3.3 Configure Remote State (Recommended)
|
||||
|
||||
Uncomment and configure the `backend "gcs"` block in `terraform/environments/gcp/main.tf`:
|
||||
|
||||
```hcl
|
||||
backend "gcs" {
|
||||
bucket = "your-terraform-state-bucket"
|
||||
prefix = "agentidp/gcp/production"
|
||||
}
|
||||
```
|
||||
|
||||
Create the GCS bucket:
|
||||
|
||||
```bash
|
||||
gsutil mb -l us-central1 gs://your-terraform-state-bucket
|
||||
gsutil versioning set on gs://your-terraform-state-bucket
|
||||
```
|
||||
|
||||
### 3.4 Terraform Init
|
||||
|
||||
```bash
|
||||
cd terraform/environments/gcp
|
||||
terraform init
|
||||
```
|
||||
|
||||
### 3.5 Terraform Plan
|
||||
|
||||
```bash
|
||||
terraform plan -out=tfplan
|
||||
```
|
||||
|
||||
Review the plan. Expected resources: ~35–45 resources (VPC, subnet, VPC connector, service accounts, secrets, Cloud SQL, Memorystore, Cloud Run service, IAM bindings, API enablement).
|
||||
|
||||
### 3.6 Terraform Apply
|
||||
|
||||
```bash
|
||||
terraform apply tfplan
|
||||
```
|
||||
|
||||
**First apply takes 15–20 minutes** — Cloud SQL provisioning is the longest step.
|
||||
|
||||
When complete:
|
||||
|
||||
```bash
|
||||
terraform output
|
||||
```
|
||||
|
||||
Key outputs:
|
||||
- `service_url` — Cloud Run HTTPS URL (Google-managed TLS, no cert setup required)
|
||||
- `cloud_sql_connection_name` — for Cloud SQL Proxy if needed
|
||||
- `memorystore_host` — Redis private IP
|
||||
|
||||
### 3.7 Run Database Migrations
|
||||
|
||||
Cloud Run does not support exec. Use a one-off Cloud Run Job for migrations:
|
||||
|
||||
```bash
|
||||
gcloud run jobs create agentidp-migrate \
|
||||
--image sentryagent/agentidp:1.0.0 \
|
||||
--region us-central1 \
|
||||
--command node \
|
||||
--args "scripts/db-migrate.js" \
|
||||
--set-secrets "DATABASE_URL=sentryagent-agentidp-production-database-url:latest" \
|
||||
--vpc-connector sentryagent-agentidp-production-connector \
|
||||
--service-account sentryagent-agentidp-production-run-sa@your-gcp-project-id.iam.gserviceaccount.com
|
||||
|
||||
gcloud run jobs execute agentidp-migrate --region us-central1 --wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Post-Deploy Verification
|
||||
|
||||
Run these checks after deploying to either environment. Replace `https://idp.example.com` with your actual service URL.
|
||||
|
||||
### 4.1 Health Check
|
||||
|
||||
```bash
|
||||
curl -si https://idp.example.com/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```
|
||||
HTTP/2 200
|
||||
content-type: application/json
|
||||
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
If you receive a 502 or 503, the load balancer has not yet registered healthy targets. Wait 60–90 seconds and retry — ECS tasks or Cloud Run instances take time to pass health checks.
|
||||
|
||||
### 4.2 Metrics Endpoint
|
||||
|
||||
```bash
|
||||
curl -si https://idp.example.com/metrics
|
||||
```
|
||||
|
||||
Expected: HTTP 200 with Prometheus-format metrics text (lines beginning with `# HELP`, `# TYPE`, and metric values).
|
||||
|
||||
### 4.3 Token Endpoint (Smoke Test)
|
||||
|
||||
First, register a test agent client (requires a valid JWT or admin credentials — see [developers guide](../developers/)):
|
||||
|
||||
```bash
|
||||
# Issue a client credentials token (replace CLIENT_ID and CLIENT_SECRET with real values)
|
||||
curl -s -X POST https://idp.example.com/api/v1/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=test-client&client_secret=test-secret&scope=read"
|
||||
```
|
||||
|
||||
Expected response (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 JWKS Endpoint
|
||||
|
||||
```bash
|
||||
curl -si https://idp.example.com/.well-known/jwks.json
|
||||
```
|
||||
|
||||
Expected: HTTP 200 with a JSON object containing a `keys` array with at least one RSA public key entry.
|
||||
|
||||
### 4.5 TLS Verification
|
||||
|
||||
```bash
|
||||
# Verify TLS certificate is valid and matches your domain
|
||||
curl -vI https://idp.example.com 2>&1 | grep -E "(SSL|TLS|certificate|issuer|subject)"
|
||||
```
|
||||
|
||||
Expected: TLS 1.2 or 1.3, certificate issued by a trusted CA, subject matching your domain.
|
||||
|
||||
### 4.6 AWS-Specific: ECS Service Status
|
||||
|
||||
```bash
|
||||
aws ecs describe-services \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--services sentryagent-agentidp-production \
|
||||
--query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount,status:status}'
|
||||
```
|
||||
|
||||
Expected: `running` equals `desired`, `status` is `ACTIVE`.
|
||||
|
||||
### 4.7 GCP-Specific: Cloud Run Service Status
|
||||
|
||||
```bash
|
||||
gcloud run services describe sentryagent-agentidp-production \
|
||||
--region us-central1 \
|
||||
--format='value(status.conditions[0].type,status.conditions[0].status)'
|
||||
```
|
||||
|
||||
Expected: `Ready True`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback Procedure
|
||||
|
||||
### 5.1 Image Rollback (Recommended — fastest)
|
||||
|
||||
To roll back to a previous image tag without modifying infrastructure:
|
||||
|
||||
**AWS:**
|
||||
|
||||
```bash
|
||||
# Find the previous task definition revision
|
||||
aws ecs list-task-definitions \
|
||||
--family-prefix sentryagent-agentidp-production \
|
||||
--sort DESC \
|
||||
--query 'taskDefinitionArns[:5]'
|
||||
|
||||
# Update the service to use the previous task definition
|
||||
aws ecs update-service \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--service sentryagent-agentidp-production \
|
||||
--task-definition sentryagent-agentidp-production:PREVIOUS_REVISION \
|
||||
--force-new-deployment
|
||||
|
||||
# Monitor the rollout
|
||||
aws ecs wait services-stable \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--services sentryagent-agentidp-production
|
||||
```
|
||||
|
||||
**GCP:**
|
||||
|
||||
```bash
|
||||
# Deploy the previous image tag directly
|
||||
gcloud run services update sentryagent-agentidp-production \
|
||||
--region us-central1 \
|
||||
--image sentryagent/agentidp:PREVIOUS_TAG
|
||||
|
||||
# Or route 100% of traffic to a specific revision
|
||||
gcloud run services update-traffic sentryagent-agentidp-production \
|
||||
--region us-central1 \
|
||||
--to-revisions PREVIOUS_REVISION_NAME=100
|
||||
```
|
||||
|
||||
### 5.2 Infrastructure Rollback via Terraform
|
||||
|
||||
If an infrastructure change (not an image update) caused the problem:
|
||||
|
||||
```bash
|
||||
# Check the state and plan to understand what changed
|
||||
terraform show
|
||||
terraform plan
|
||||
|
||||
# If you have a previous state file (S3/GCS versioning), restore it:
|
||||
# AWS:
|
||||
aws s3 cp s3://your-state-bucket/agentidp/aws/production/terraform.tfstate.PREVIOUS ./terraform.tfstate
|
||||
terraform apply -target=<affected_resource>
|
||||
|
||||
# GCP:
|
||||
gsutil cp gs://your-state-bucket/agentidp/gcp/production/PREVIOUS_VERSION ./terraform.tfstate
|
||||
terraform apply -target=<affected_resource>
|
||||
```
|
||||
|
||||
**Never run `terraform destroy` in production without CEO approval.**
|
||||
|
||||
### 5.3 Database Rollback
|
||||
|
||||
RDS (AWS) and Cloud SQL (GCP) both support point-in-time restore. Use this only as a last resort — it creates a new DB instance and requires updating the `DATABASE_URL` secret.
|
||||
|
||||
**AWS:**
|
||||
|
||||
```bash
|
||||
# Restore to a point before the problematic deployment
|
||||
aws rds restore-db-instance-to-point-in-time \
|
||||
--source-db-instance-identifier sentryagent-agentidp-production \
|
||||
--target-db-instance-identifier sentryagent-agentidp-production-restored \
|
||||
--restore-time 2026-01-01T12:00:00Z
|
||||
```
|
||||
|
||||
**GCP:**
|
||||
|
||||
```bash
|
||||
# List available backups
|
||||
gcloud sql backups list --instance sentryagent-agentidp-production-pg14
|
||||
|
||||
# Restore from a backup
|
||||
gcloud sql backups restore BACKUP_ID \
|
||||
--restore-instance sentryagent-agentidp-production-pg14
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Environment Variable Reference
|
||||
|
||||
All environment variables injected into the AgentIdP container are documented in full at:
|
||||
|
||||
**[docs/devops/environment-variables.md](./environment-variables.md)**
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Variable | Required | Source (AWS) | Source (GCP) |
|
||||
|----------|----------|--------------|--------------|
|
||||
| `DATABASE_URL` | Yes | Secrets Manager: `/<project>/<env>/database-url` | Secret Manager: `<name-prefix>-database-url` |
|
||||
| `REDIS_URL` | Yes | Secrets Manager: `/<project>/<env>/redis-url` | Secret Manager: `<name-prefix>-redis-url` |
|
||||
| `JWT_PRIVATE_KEY` | Yes | Secrets Manager: `/<project>/<env>/jwt-private-key` | Secret Manager: `<name-prefix>-jwt-private-key` |
|
||||
| `JWT_PUBLIC_KEY` | Yes | Secrets Manager: `/<project>/<env>/jwt-public-key` | Secret Manager: `<name-prefix>-jwt-public-key` |
|
||||
| `PORT` | No | Task definition env var (default: 3000) | Cloud Run env var (default: 3000) |
|
||||
| `NODE_ENV` | No | Task definition env var (`production`) | Cloud Run env var (`production`) |
|
||||
| `CORS_ORIGIN` | No | Task definition env var | Cloud Run env var |
|
||||
| `POLICY_DIR` | No | Task definition env var (`/app/policies`) | Cloud Run env var (`/app/policies`) |
|
||||
| `VAULT_ADDR` | No | Task definition env var | Cloud Run env var |
|
||||
| `VAULT_TOKEN` | No | Secrets Manager: `/<project>/<env>/vault-token` | Secret Manager: `<name-prefix>-vault-token` |
|
||||
| `VAULT_MOUNT` | No | Task definition env var (default: `secret`) | Cloud Run env var (default: `secret`) |
|
||||
|
||||
### Updating a Secret
|
||||
|
||||
**AWS:**
|
||||
|
||||
```bash
|
||||
# Update a secret value (e.g. rotate JWT keys)
|
||||
aws secretsmanager put-secret-value \
|
||||
--secret-id /sentryagent-agentidp/production/jwt-private-key \
|
||||
--secret-string "$(cat new_jwt_private.pem)"
|
||||
|
||||
# Force new ECS deployment to pick up the new secret value
|
||||
aws ecs update-service \
|
||||
--cluster sentryagent-agentidp-production \
|
||||
--service sentryagent-agentidp-production \
|
||||
--force-new-deployment
|
||||
```
|
||||
|
||||
**GCP:**
|
||||
|
||||
```bash
|
||||
# Add a new version of a secret
|
||||
gcloud secrets versions add sentryagent-agentidp-production-jwt-private-key \
|
||||
--data-file=new_jwt_private.pem
|
||||
|
||||
# Deploy a new Cloud Run revision to pick up the latest secret version
|
||||
gcloud run services update sentryagent-agentidp-production \
|
||||
--region us-central1 \
|
||||
--image sentryagent/agentidp:CURRENT_TAG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
### AWS
|
||||
|
||||
```
|
||||
Route 53 (A alias)
|
||||
└── ALB (public subnets, HTTPS/443, ACM cert, HTTP→HTTPS redirect)
|
||||
└── Target Group
|
||||
└── ECS Fargate Service (private subnets, 2+ tasks)
|
||||
├── Secrets Manager (DATABASE_URL, REDIS_URL, JWT keys)
|
||||
├── RDS PostgreSQL 14 (private subnets, Multi-AZ, encrypted)
|
||||
└── ElastiCache Redis 7 (private subnets, primary+replica, TLS)
|
||||
```
|
||||
|
||||
### GCP
|
||||
|
||||
```
|
||||
Internet → Cloud Run Service (Google-managed TLS, auto-scaling)
|
||||
├── Secret Manager (DATABASE_URL, REDIS_URL, JWT keys)
|
||||
├── Serverless VPC Connector
|
||||
│ ├── Cloud SQL PostgreSQL 14 (private IP, REGIONAL HA)
|
||||
│ └── Memorystore Redis 7 (STANDARD_HA, TLS)
|
||||
```
|
||||
|
||||
Both environments share the same Docker image (`sentryagent/agentidp`) and the same JWT key pair — tokens issued in one region are verifiable in the other.
|
||||
@@ -76,6 +76,62 @@ Every authenticated request verifies the JWT signature using this key. If this k
|
||||
|
||||
These variables have defaults and do not need to be set for local development.
|
||||
|
||||
### `VAULT_ADDR`
|
||||
|
||||
HashiCorp Vault server address. **Required to enable Vault integration (Phase 2).**
|
||||
|
||||
| | |
|
||||
|-|-|
|
||||
| **Required** | No (Vault is optional) |
|
||||
| **Format** | URL string |
|
||||
| **Example** | `VAULT_ADDR=http://127.0.0.1:8200` |
|
||||
|
||||
When set alongside `VAULT_TOKEN`, new credentials are stored in Vault KV v2 instead of as bcrypt hashes in PostgreSQL. Existing bcrypt credentials continue to work unchanged until rotated. See [Vault setup guide](vault-setup.md).
|
||||
|
||||
---
|
||||
|
||||
### `VAULT_TOKEN`
|
||||
|
||||
Vault authentication token. Required when `VAULT_ADDR` is set.
|
||||
|
||||
| | |
|
||||
|-|-|
|
||||
| **Required** | Only when `VAULT_ADDR` is set |
|
||||
| **Format** | String |
|
||||
| **Example** | `VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX` |
|
||||
|
||||
Use a Vault service token scoped to `read`, `write`, and `delete` on `{VAULT_MOUNT}/data/agentidp/*` and `{VAULT_MOUNT}/metadata/agentidp/*`.
|
||||
|
||||
---
|
||||
|
||||
### `VAULT_MOUNT`
|
||||
|
||||
KV v2 secrets engine mount path.
|
||||
|
||||
| | |
|
||||
|-|-|
|
||||
| **Required** | No |
|
||||
| **Default** | `secret` |
|
||||
| **Format** | String (no leading or trailing slash) |
|
||||
| **Example** | `VAULT_MOUNT=agentidp` |
|
||||
|
||||
---
|
||||
|
||||
### `POLICY_DIR`
|
||||
|
||||
Directory containing OPA policy files (`authz.rego`, `authz.wasm`, `data/scopes.json`).
|
||||
|
||||
| | |
|
||||
|-|-|
|
||||
| **Required** | No |
|
||||
| **Default** | `<cwd>/policies` |
|
||||
| **Format** | Absolute or relative directory path |
|
||||
| **Example** | `POLICY_DIR=/etc/sentryagent/policies` |
|
||||
|
||||
At startup the OPA authorization middleware loads `${POLICY_DIR}/authz.wasm` (Wasm mode) if present; otherwise it loads `${POLICY_DIR}/data/scopes.json` (fallback mode). Send `SIGHUP` to the process to hot-reload the policy files without a restart.
|
||||
|
||||
---
|
||||
|
||||
### `PORT`
|
||||
|
||||
HTTP port the Express server listens on.
|
||||
@@ -141,6 +197,14 @@ MIIEowIBAAKCAQEA...
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkq...
|
||||
-----END PUBLIC KEY-----"
|
||||
|
||||
# HashiCorp Vault (Phase 2 — optional, omit to use bcrypt mode)
|
||||
# VAULT_ADDR=http://127.0.0.1:8200
|
||||
# VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX
|
||||
# VAULT_MOUNT=secret
|
||||
|
||||
# OPA Policy Engine (Phase 2 — optional, defaults to <cwd>/policies)
|
||||
# POLICY_DIR=/etc/sentryagent/policies
|
||||
```
|
||||
|
||||
> Do not commit `.env` to version control. Add it to `.gitignore`.
|
||||
|
||||
@@ -247,3 +247,38 @@ docker-compose exec redis redis-cli GET "rate:<client_id>:$WINDOW"
|
||||
```
|
||||
|
||||
**Fix:** Wait until `X-RateLimit-Reset` (Unix timestamp in the response header) before retrying. The window resets every 60 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
AgentIdP exposes a Prometheus metrics endpoint at `GET /metrics` (unauthenticated, plain text).
|
||||
|
||||
### Metrics Exposed
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `agentidp_tokens_issued_total` | Counter | `scope` | OAuth 2.0 tokens issued successfully |
|
||||
| `agentidp_agents_registered_total` | Counter | `deployment_env` | Agents registered successfully |
|
||||
| `agentidp_http_requests_total` | Counter | `method`, `route`, `status_code` | HTTP requests received |
|
||||
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `route`, `status_code` | HTTP request duration |
|
||||
| `agentidp_db_query_duration_seconds` | Histogram | `operation` | PostgreSQL query duration |
|
||||
| `agentidp_redis_command_duration_seconds` | Histogram | `command` | Redis command duration |
|
||||
|
||||
### Starting the Monitoring Stack
|
||||
|
||||
```bash
|
||||
# Start the full stack with monitoring
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
|
||||
# Prometheus: http://localhost:9090
|
||||
# Grafana: http://localhost:3001 (admin / agentidp)
|
||||
```
|
||||
|
||||
The Grafana dashboard auto-provisions on first start. Navigate to **Dashboards → AgentIdP → SentryAgent.ai — AgentIdP**.
|
||||
|
||||
### Security Note
|
||||
|
||||
`GET /metrics` is unauthenticated. In production, ensure this endpoint is:
|
||||
- Only accessible from your internal network (firewall rule or reverse proxy restriction)
|
||||
- Not exposed on a public-facing port
|
||||
|
||||
197
docs/devops/vault-setup.md
Normal file
197
docs/devops/vault-setup.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# HashiCorp Vault Setup
|
||||
|
||||
Phase 2 of AgentIdP optionally stores credential secrets in [HashiCorp Vault](https://www.vaultproject.io/) KV v2 instead of bcrypt hashes in PostgreSQL. This guide covers:
|
||||
|
||||
- Dev mode quickstart
|
||||
- Production Vault configuration
|
||||
- Migration from bcrypt to Vault
|
||||
|
||||
Vault is **entirely optional**. If `VAULT_ADDR` is not set, AgentIdP operates in bcrypt mode (identical to Phase 1 behaviour).
|
||||
|
||||
---
|
||||
|
||||
## How Vault integration works
|
||||
|
||||
When enabled:
|
||||
|
||||
1. `POST /api/v1/agents/{agentId}/credentials` — the plain-text secret is written to Vault at `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`. Only the Vault path is stored in PostgreSQL (`credentials.vault_path`). No bcrypt hash is written.
|
||||
2. `POST /api/v1/token` — the submitted `client_secret` is compared against the value read from Vault (constant-time comparison). No bcrypt is involved.
|
||||
3. `POST /api/v1/agents/{agentId}/credentials/{credentialId}/rotate` — a new Vault version is written (KV v2 versioning). The path is unchanged; the old version is retained in Vault history.
|
||||
4. `DELETE /api/v1/agents/{agentId}/credentials/{credentialId}` — all versions of the secret are permanently deleted from Vault.
|
||||
|
||||
**Coexistence**: Credentials created before Vault was enabled keep their bcrypt hash and continue to work. New credentials use Vault. Both paths coexist until all pre-Vault credentials are rotated.
|
||||
|
||||
---
|
||||
|
||||
## Dev mode quickstart
|
||||
|
||||
The fastest way to get Vault running locally:
|
||||
|
||||
```bash
|
||||
# Pull and start Vault in dev mode (in-memory, auto-unsealed)
|
||||
docker run --rm -d \
|
||||
--name vault-dev \
|
||||
-p 8200:8200 \
|
||||
-e VAULT_DEV_ROOT_TOKEN_ID=dev-root-token \
|
||||
hashicorp/vault:1.15 server -dev
|
||||
|
||||
# Verify it is running
|
||||
curl http://127.0.0.1:8200/v1/sys/health | jq .
|
||||
```
|
||||
|
||||
Add to your `.env`:
|
||||
|
||||
```
|
||||
VAULT_ADDR=http://127.0.0.1:8200
|
||||
VAULT_TOKEN=dev-root-token
|
||||
VAULT_MOUNT=secret
|
||||
```
|
||||
|
||||
The KV v2 secrets engine is automatically enabled at `secret/` in dev mode. No further configuration is needed.
|
||||
|
||||
> **Warning**: Dev mode stores everything in memory. Data is lost when the container stops. Do not use dev mode in production.
|
||||
|
||||
---
|
||||
|
||||
## Production Vault configuration
|
||||
|
||||
### 1. Enable KV v2 secrets engine
|
||||
|
||||
```bash
|
||||
vault secrets enable -path=secret kv-v2
|
||||
```
|
||||
|
||||
Or use a custom mount path:
|
||||
|
||||
```bash
|
||||
vault secrets enable -path=agentidp kv-v2
|
||||
# Set VAULT_MOUNT=agentidp in your .env
|
||||
```
|
||||
|
||||
### 2. Create a policy for AgentIdP
|
||||
|
||||
```hcl
|
||||
# agentidp-policy.hcl
|
||||
path "secret/data/agentidp/*" {
|
||||
capabilities = ["create", "read", "update", "delete"]
|
||||
}
|
||||
|
||||
path "secret/metadata/agentidp/*" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
```
|
||||
|
||||
Apply the policy:
|
||||
|
||||
```bash
|
||||
vault policy write agentidp agentidp-policy.hcl
|
||||
```
|
||||
|
||||
### 3. Create a service token
|
||||
|
||||
```bash
|
||||
vault token create \
|
||||
-policy=agentidp \
|
||||
-ttl=8760h \
|
||||
-renewable=true \
|
||||
-display-name="agentidp-service"
|
||||
```
|
||||
|
||||
Copy the `token` field from the output and set it as `VAULT_TOKEN` in your environment.
|
||||
|
||||
### 4. Token renewal
|
||||
|
||||
Service tokens expire unless renewed. Set up a scheduled renewal before the TTL expires:
|
||||
|
||||
```bash
|
||||
# Renew with a new 720-hour (30-day) lease
|
||||
vault token renew -increment=720h <token>
|
||||
```
|
||||
|
||||
In Kubernetes, use Vault Agent Injector or the Vault Secrets Operator to handle renewal automatically.
|
||||
|
||||
---
|
||||
|
||||
## Running migration 005
|
||||
|
||||
After configuring Vault, run the migration to add the `vault_path` column:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
Verify the migration:
|
||||
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'credentials'
|
||||
ORDER BY ordinal_position;
|
||||
```
|
||||
|
||||
You should see a `vault_path` column with `data_type = text` and `is_nullable = YES`.
|
||||
|
||||
---
|
||||
|
||||
## Migrating existing credentials to Vault
|
||||
|
||||
Existing credentials (with `vault_path IS NULL`) continue to work via bcrypt until they are rotated. To migrate a credential:
|
||||
|
||||
```bash
|
||||
# Rotate the credential — this writes the new secret to Vault
|
||||
curl -s -X POST http://localhost:3000/api/v1/agents/$AGENT_ID/credentials/$CRED_ID/rotate \
|
||||
-H "Authorization: Bearer $TOKEN" | jq .
|
||||
```
|
||||
|
||||
The response includes the new `clientSecret` (store it immediately). After rotation, `vault_path` is set and the bcrypt hash is cleared.
|
||||
|
||||
To migrate all credentials for an agent in bulk, rotate them one by one using the API.
|
||||
|
||||
---
|
||||
|
||||
## Verifying Vault secrets
|
||||
|
||||
After generating a credential with Vault enabled, verify the secret was written:
|
||||
|
||||
```bash
|
||||
vault kv get secret/agentidp/agents/$AGENT_ID/credentials/$CRED_ID
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
====== Secret Path ======
|
||||
secret/data/agentidp/agents/<agentId>/credentials/<credentialId>
|
||||
|
||||
======= Metadata =======
|
||||
Key Value
|
||||
--- -----
|
||||
created_time 2026-03-28T...
|
||||
version 1
|
||||
|
||||
====== Data ======
|
||||
Key Value
|
||||
--- -----
|
||||
clientSecret <the secret>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `VAULT_WRITE_ERROR` on credential generation
|
||||
|
||||
- Verify Vault is running: `curl $VAULT_ADDR/v1/sys/health`
|
||||
- Verify the token has write access: `vault token capabilities $VAULT_TOKEN secret/data/agentidp/test`
|
||||
- Check Vault audit logs: `vault audit list`
|
||||
|
||||
### `VAULT_READ_ERROR` on token issuance
|
||||
|
||||
- Verify the `vault_path` stored in PostgreSQL matches the actual Vault path
|
||||
- Check the token has read access to `secret/data/agentidp/*`
|
||||
|
||||
### Vault is down — what happens?
|
||||
|
||||
If Vault is unreachable, credential generation and token issuance for Vault-backed credentials will fail with a `500` error. Credentials created before Vault was enabled (bcrypt mode) continue to work.
|
||||
|
||||
For high availability, run Vault in HA mode with an integrated Raft storage backend. See [Vault HA documentation](https://developer.hashicorp.com/vault/docs/concepts/ha).
|
||||
226
monitoring/grafana/dashboards/agentidp.json
Normal file
226
monitoring/grafana/dashboards/agentidp.json
Normal file
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "SentryAgent.ai AgentIdP — Application Overview",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(agentidp_tokens_issued_total[1m])",
|
||||
"legendFormat": "scope={{ scope }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Tokens Issued / min",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(agentidp_agents_registered_total[1m])",
|
||||
"legendFormat": "env={{ deployment_env }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Agents Registered / min",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(agentidp_http_requests_total[1m])",
|
||||
"legendFormat": "{{ method }} {{ route }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "HTTP Request Rate / min",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 0.01 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(agentidp_http_requests_total{status_code=~\"5..\"}[1m])",
|
||||
"legendFormat": "{{ method }} {{ route }} {{ status_code }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "HTTP Error Rate (5xx)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 },
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.99, rate(agentidp_http_request_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "p99 {{ method }} {{ route }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "HTTP P99 Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 },
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, rate(agentidp_db_query_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "p95 {{ operation }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "DB Query P95 Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 10 },
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, rate(agentidp_redis_command_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "p95 {{ command }}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Redis Command P95 Latency",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["agentidp", "sentryagent"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "SentryAgent.ai — AgentIdP",
|
||||
"uid": "agentidp-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
11
monitoring/grafana/provisioning/dashboards/provider.yml
Normal file
11
monitoring/grafana/provisioning/dashboards/provider.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: AgentIdP
|
||||
orgId: 1
|
||||
folder: AgentIdP
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
10
monitoring/prometheus/prometheus.yml
Normal file
10
monitoring/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'agentidp'
|
||||
static_configs:
|
||||
- targets: ['agentidp:3000']
|
||||
metrics_path: /metrics
|
||||
scheme: http
|
||||
3
openspec/changes/phase-2-production-ready/.openspec.yaml
Normal file
3
openspec/changes/phase-2-production-ready/.openspec.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
change: phase-2-production-ready
|
||||
status: proposed
|
||||
date: 2026-03-28
|
||||
218
openspec/changes/phase-2-production-ready/design.md
Normal file
218
openspec/changes/phase-2-production-ready/design.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Phase 2: Production-Ready — Technical Design
|
||||
|
||||
**Date**: 2026-03-28
|
||||
**Author**: Virtual Architect
|
||||
**Status**: Draft — pending CEO approval of proposal
|
||||
|
||||
---
|
||||
|
||||
## 1. HashiCorp Vault Integration
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
AgentIdP Server
|
||||
└── CredentialService
|
||||
└── VaultClient (new)
|
||||
└── HashiCorp Vault (sidecar or external)
|
||||
└── KV Secrets Engine v2
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
**ADR-001: Vault over AWS KMS/GCP Secret Manager**
|
||||
Vault is cloud-agnostic, open-source, and already standard in enterprise environments. Using Vault keeps Phase 2 cloud-provider independent.
|
||||
|
||||
**ADR-002: KV Secrets Engine v2**
|
||||
KV v2 provides versioned secrets and metadata. When a credential is rotated, the old version is retained in Vault history, enabling audit-grade secret lifecycle tracking.
|
||||
|
||||
**ADR-003: AgentIdP stores Vault path, not secret**
|
||||
`credentials.vault_path` stores the Vault KV path (e.g. `secret/agentidp/agents/{agentId}/credentials/{credentialId}`). The secret itself is never written to PostgreSQL.
|
||||
|
||||
### New environment variables
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `VAULT_ADDR` | Vault server address |
|
||||
| `VAULT_TOKEN` | Vault root/service token |
|
||||
| `VAULT_MOUNT` | KV mount path (default: `secret`) |
|
||||
|
||||
### Migration
|
||||
Add `vault_path` column to `credentials` table (`005_add_vault_path.sql`). Existing credentials retain bcrypt hashes; new credentials use Vault. Both code paths coexist until all credentials are rotated (migration guide provided).
|
||||
|
||||
---
|
||||
|
||||
## 2. Multi-Language SDKs
|
||||
|
||||
### Shared contract (all SDKs implement identically)
|
||||
|
||||
```
|
||||
AgentIdPClient(baseUrl, clientId, clientSecret, scopes?)
|
||||
.agents → AgentRegistryClient (5 methods)
|
||||
.credentials → CredentialClient (4 methods)
|
||||
.tokens → TokenClient (2 methods)
|
||||
.audit → AuditClient (2 methods)
|
||||
.clearTokenCache()
|
||||
|
||||
TokenManager — auto-refresh 60s before expiry
|
||||
AgentIdPError — code, message, httpStatus, details
|
||||
```
|
||||
|
||||
### Python SDK (`sentryagent-idp`)
|
||||
- Python 3.9+ (httpx for async, requests for sync)
|
||||
- Both sync and async client variants
|
||||
- PyPI package: `sentryagent-idp`
|
||||
- Type hints throughout (`mypy --strict` clean)
|
||||
|
||||
### Go SDK (`github.com/sentryagent/idp-sdk-go`)
|
||||
- Go 1.21+, standard library `net/http`
|
||||
- Context-aware methods (`context.Context` first arg)
|
||||
- Idiomatic Go error handling (`error` return, no panic)
|
||||
- Go module: `github.com/sentryagent/idp-sdk-go`
|
||||
|
||||
### Java SDK (`ai.sentryagent:idp-sdk`)
|
||||
- Java 17+, Apache HttpClient 5
|
||||
- Synchronous and CompletableFuture async variants
|
||||
- Maven Central: `ai.sentryagent:idp-sdk`
|
||||
- Fully typed with generics
|
||||
|
||||
---
|
||||
|
||||
## 3. OPA Policy Engine
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
→ Auth Middleware (JWT verify) — unchanged
|
||||
→ OPA Middleware (new) — evaluates policy
|
||||
→ OPA Wasm (embedded, no network call)
|
||||
→ Rego policy files (hot-reloadable)
|
||||
→ Controller
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
**ADR-004: OPA Wasm over OPA sidecar**
|
||||
Embedding OPA as Wasm in the Node.js process eliminates a network hop and removes a runtime dependency. Policy files are loaded from `policies/` directory at startup and reloaded on SIGHUP.
|
||||
|
||||
**ADR-005: Policy replaces, does not wrap, scope check**
|
||||
The existing static scope check in `auth.ts` is replaced by an OPA policy evaluation. This keeps the policy as the single source of truth for access control.
|
||||
|
||||
### Policy structure (`policies/`)
|
||||
```
|
||||
policies/
|
||||
authz.rego — main policy: allow/deny
|
||||
data/
|
||||
scopes.json — scope → permission mapping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Web Dashboard UI
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
dashboard/ (new — separate from sdk/)
|
||||
src/
|
||||
components/ — reusable UI components
|
||||
pages/ — Agents, Credentials, Audit, Health
|
||||
hooks/ — useAgents, useCredentials, useAudit
|
||||
lib/
|
||||
client.ts — wraps @sentryagent/idp-sdk
|
||||
auth.ts — credential entry and storage
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
- React 18 + TypeScript strict
|
||||
- Vite 5 (build tool)
|
||||
- TanStack Query v5 (server state)
|
||||
- shadcn/ui components (Radix UI + Tailwind CSS)
|
||||
|
||||
### Pages
|
||||
| Page | Scope Required | Features |
|
||||
|------|---------------|----------|
|
||||
| Agents | `agents:read` | List, search, view detail, suspend/reactivate |
|
||||
| Credentials | `agents:read` | List credentials per agent, rotate, revoke |
|
||||
| Audit Log | `audit:read` | Filter by agent/action/outcome/date, paginate |
|
||||
| Health | None | Server uptime, Redis/PostgreSQL connectivity |
|
||||
|
||||
### Authentication
|
||||
The dashboard accepts `clientId` + `clientSecret` via a login form. The `@sentryagent/idp-sdk` `TokenManager` handles token acquisition and caching in `sessionStorage`. No backend session — all state is client-side.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prometheus + Grafana Monitoring
|
||||
|
||||
### Metrics exposed at `GET /metrics`
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `agentidp_tokens_issued_total` | Counter | Tokens issued, labelled by outcome |
|
||||
| `agentidp_agents_registered_total` | Counter | Agent registrations |
|
||||
| `agentidp_http_requests_total` | Counter | All requests, labelled by method/path/status |
|
||||
| `agentidp_http_request_duration_seconds` | Histogram | Request latency |
|
||||
| `agentidp_rate_limit_rejections_total` | Counter | 429 responses |
|
||||
| `agentidp_db_query_duration_seconds` | Histogram | PostgreSQL query latency |
|
||||
| `agentidp_redis_command_duration_seconds` | Histogram | Redis command latency |
|
||||
|
||||
### Grafana dashboard
|
||||
Pre-built JSON dashboard shipped in `monitoring/grafana/dashboards/agentidp.json`. Auto-provisioned via `monitoring/grafana/provisioning/`.
|
||||
|
||||
### Docker Compose extension
|
||||
Add `prometheus` and `grafana` services to a `docker-compose.monitoring.yml` overlay — keeps the base `docker-compose.yml` clean for developers who don't need monitoring.
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-Region Deployment (Terraform)
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
terraform/
|
||||
modules/
|
||||
agentidp/ — reusable module: compute + networking
|
||||
rds/ — managed PostgreSQL
|
||||
redis/ — managed Redis
|
||||
lb/ — load balancer + TLS
|
||||
environments/
|
||||
aws/ — AWS-specific config (ECS + RDS + ElastiCache)
|
||||
gcp/ — GCP-specific config (Cloud Run + Cloud SQL + Memorystore)
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
**ADR-006: Two provider targets (AWS + GCP) in Phase 2**
|
||||
AWS and GCP cover the majority of developer deployments. Azure module is Phase 3. Each environment is a thin wrapper over the shared `agentidp` module.
|
||||
|
||||
**ADR-007: Terraform over Pulumi/CDK**
|
||||
Terraform is the most widely-used IaC tool, familiar to most DevOps teams. The HCL syntax is simpler for documentation purposes.
|
||||
|
||||
---
|
||||
|
||||
## Component Interaction Map (Phase 2)
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ (React + Vite) │
|
||||
└────────┬───────────┘
|
||||
│ HTTPS
|
||||
┌────────────────▼────────────────┐
|
||||
│ AgentIdP Server │
|
||||
│ Auth MW → OPA MW → Controllers │
|
||||
│ /metrics (prom-client) │
|
||||
└──┬──────────┬──────────┬────────┘
|
||||
│ │ │
|
||||
┌─────▼──┐ ┌────▼───┐ ┌──▼───────┐
|
||||
│Postgres│ │ Redis │ │ Vault │
|
||||
└────────┘ └────────┘ └──────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Prometheus │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Grafana │
|
||||
└─────────────────┘
|
||||
```
|
||||
96
openspec/changes/phase-2-production-ready/proposal.md
Normal file
96
openspec/changes/phase-2-production-ready/proposal.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Phase 2: Production-Ready — Change Proposal
|
||||
|
||||
**Date**: 2026-03-28
|
||||
**Author**: Virtual CTO
|
||||
**Status**: Proposed — awaiting CEO approval
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 1 delivered a complete, working AgentIdP MVP. Phase 2 makes it production-ready: hardened secrets management, multi-language SDKs, a policy engine, a web dashboard, observability, and multi-region deployment.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Phase 1 is functional but has the following production gaps:
|
||||
|
||||
| Gap | Risk |
|
||||
|-----|------|
|
||||
| Credentials stored as bcrypt hashes in PostgreSQL | No HSM/KMS — acceptable for MVP, not for enterprise |
|
||||
| Only Node.js SDK | Developers in Python/Go/Java cannot use the SDK |
|
||||
| No policy engine | Scope enforcement is static — no dynamic ABAC/RBAC |
|
||||
| No web UI | Operators must use `curl` to manage agents |
|
||||
| No observability | No metrics, no dashboards, no alerting |
|
||||
| Single-region deployment | No HA, no geo-redundancy |
|
||||
|
||||
---
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. HashiCorp Vault Integration
|
||||
Replace raw bcrypt credential storage with Vault-backed secret management. Vault handles secret generation, versioning, and revocation. AgentIdP stores only Vault secret paths, not the secrets themselves.
|
||||
|
||||
### 2. Multi-Language SDKs
|
||||
Add Python, Go, and Java SDKs with identical API surface to the existing Node.js SDK: `AgentIdPClient`, `TokenManager`, service clients for all 14 endpoints, typed error hierarchy.
|
||||
|
||||
### 3. Advanced Policy Engine (OPA)
|
||||
Integrate Open Policy Agent (OPA) as a sidecar for dynamic scope and attribute-based access control. Policies are hot-reloadable Rego files — no server restart required.
|
||||
|
||||
### 4. Web Dashboard UI
|
||||
A React + TypeScript dashboard for operators: agent list and management, credential overview, audit log viewer, system health panel. Read-only by default; write operations require `agents:write` scope.
|
||||
|
||||
### 5. Prometheus + Grafana Monitoring
|
||||
Instrument all services with Prometheus metrics (`/metrics` endpoint). Ship a pre-built Grafana dashboard for: token issuance rate, agent registration rate, error rates, Redis latency, PostgreSQL query latency.
|
||||
|
||||
### 6. Multi-Region Deployment
|
||||
Terraform modules for AWS/GCP deployment with: managed PostgreSQL (RDS/Cloud SQL), managed Redis (ElastiCache/Memorystore), container orchestration (ECS/Cloud Run), load balancer, and a deployment guide.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope for Phase 2
|
||||
|
||||
- AGNTCY federation (Phase 3)
|
||||
- W3C DID support (Phase 3)
|
||||
- SOC 2 certification (Phase 3)
|
||||
- Rust/C++ SDKs (Phase 3)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| New Dependency | Purpose | CEO Approval Required |
|
||||
|---------------|---------|----------------------|
|
||||
| `@openpolicyagent/opa-wasm` | OPA policy evaluation | Yes |
|
||||
| `node-vault` | HashiCorp Vault client | Yes |
|
||||
| React 18 + Vite | Web dashboard | Yes |
|
||||
| `prom-client` | Prometheus metrics | Yes |
|
||||
| Terraform | Infrastructure as code | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Delivery Sequence (per OpenSpec spec-first workflow)
|
||||
|
||||
```
|
||||
1. Vault integration (highest security impact)
|
||||
2. Python SDK (highest developer demand)
|
||||
3. Go SDK
|
||||
4. Java SDK
|
||||
5. OPA policy engine
|
||||
6. Web dashboard UI
|
||||
7. Prometheus + Grafana monitoring
|
||||
8. Multi-region deployment (Terraform)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- All new dependencies CEO-approved before implementation begins
|
||||
- All new API endpoints have OpenAPI 3.0 specs before implementation
|
||||
- TypeScript strict mode + zero `any` maintained throughout
|
||||
- >80% test coverage on all new services
|
||||
- All SDKs pass the same QA gate: 14-endpoint coverage, typed errors, zero `any`
|
||||
- Web dashboard passes OWASP Top 10 security review
|
||||
- Monitoring stack ships with pre-built dashboards — zero manual setup required
|
||||
@@ -0,0 +1,44 @@
|
||||
# Spec: Multi-Region Deployment (Terraform)
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 8 of 8
|
||||
|
||||
## Scope
|
||||
- `terraform/` directory at project root
|
||||
- Shared `agentidp` module (compute, networking, secrets)
|
||||
- `environments/aws/` — ECS Fargate + RDS PostgreSQL + ElastiCache Redis
|
||||
- `environments/gcp/` — Cloud Run + Cloud SQL + Memorystore Redis
|
||||
- Deployment guide: `docs/devops/deployment.md`
|
||||
|
||||
## Module structure
|
||||
|
||||
```
|
||||
terraform/
|
||||
modules/
|
||||
agentidp/
|
||||
main.tf — compute (ECS task or Cloud Run service)
|
||||
networking.tf — VPC, subnets, security groups
|
||||
variables.tf — all configurable inputs
|
||||
outputs.tf — service URL, DB endpoint, Redis endpoint
|
||||
rds/ — managed PostgreSQL
|
||||
redis/ — managed Redis
|
||||
lb/ — ALB (AWS) or Cloud LB (GCP), TLS cert
|
||||
environments/
|
||||
aws/
|
||||
main.tf — calls modules, sets AWS-specific vars
|
||||
variables.tf
|
||||
terraform.tfvars.example
|
||||
gcp/
|
||||
main.tf
|
||||
variables.tf
|
||||
terraform.tfvars.example
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `terraform validate` passes for both aws and gcp environments
|
||||
- [ ] `terraform plan` produces no errors against a live AWS/GCP account (test in dev env)
|
||||
- [ ] JWT_PRIVATE_KEY and JWT_PUBLIC_KEY injected as environment secrets (not hardcoded)
|
||||
- [ ] TLS termination at load balancer — HTTPS only in production modules
|
||||
- [ ] PostgreSQL and Redis not publicly accessible — VPC-internal only
|
||||
- [ ] `docs/devops/deployment.md` — end-to-end deployment walkthrough for AWS and GCP
|
||||
- [ ] `terraform.tfvars.example` provided for both environments — no secrets in version control
|
||||
@@ -0,0 +1,23 @@
|
||||
# Spec: Go SDK (`github.com/sentryagent/idp-sdk-go`)
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 3 of 8
|
||||
|
||||
## Scope
|
||||
- `sdk-go/` directory at project root
|
||||
- Context-aware `AgentIdPClient` using standard library `net/http`
|
||||
- `TokenManager` with mutex-guarded cache and 60s auto-refresh
|
||||
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
|
||||
- Idiomatic Go error type `AgentIdPError` implementing `error` interface
|
||||
- `go.mod` module: `github.com/sentryagent/idp-sdk-go`
|
||||
- `sdk-go/README.md`
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All 14 endpoints covered
|
||||
- [ ] All methods take `context.Context` as first argument
|
||||
- [ ] No panics — all errors returned as `error`
|
||||
- [ ] `AgentIdPError` implements `error` and exposes `.Code`, `.HTTPStatus`, `.Details`
|
||||
- [ ] `TokenManager` is goroutine-safe (`sync.Mutex` on cache)
|
||||
- [ ] `go vet` and `staticcheck` pass with zero warnings
|
||||
- [ ] `go test ./...` with >80% coverage
|
||||
- [ ] README matches Node.js SDK structure
|
||||
@@ -0,0 +1,23 @@
|
||||
# Spec: Java SDK (`ai.sentryagent:idp-sdk`)
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 4 of 8
|
||||
|
||||
## Scope
|
||||
- `sdk-java/` directory at project root
|
||||
- `AgentIdPClient` with sync and `CompletableFuture` async variants
|
||||
- `TokenManager` with thread-safe cache and 60s auto-refresh
|
||||
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
|
||||
- `AgentIdPException` extending `RuntimeException` with `code`, `httpStatus`, `details`
|
||||
- `pom.xml`: groupId=`ai.sentryagent`, artifactId=`idp-sdk`, Java 17+
|
||||
- `sdk-java/README.md`
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All 14 endpoints covered
|
||||
- [ ] Sync methods return typed POJOs; async methods return `CompletableFuture<T>`
|
||||
- [ ] `AgentIdPException` thrown (not raw IOException) on all failure paths
|
||||
- [ ] `TokenManager` is thread-safe (`synchronized` on cache)
|
||||
- [ ] Apache HttpClient 5 for HTTP transport
|
||||
- [ ] Jackson for JSON serialization
|
||||
- [ ] `mvn verify` passes with >80% coverage (JUnit 5)
|
||||
- [ ] README matches Node.js SDK structure
|
||||
@@ -0,0 +1,32 @@
|
||||
# Spec: Prometheus + Grafana Monitoring
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 7 of 8
|
||||
|
||||
## Scope
|
||||
- `prom-client` integration — expose `GET /metrics`
|
||||
- 7 metrics (counters + histograms) across all services
|
||||
- `monitoring/` directory: Prometheus config + Grafana provisioning
|
||||
- `docker-compose.monitoring.yml` overlay (adds prometheus + grafana services)
|
||||
- Pre-built Grafana dashboard JSON (`monitoring/grafana/dashboards/agentidp.json`)
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `agentidp_tokens_issued_total` | Counter | `outcome` (success/failure) |
|
||||
| `agentidp_agents_registered_total` | Counter | `outcome` |
|
||||
| `agentidp_http_requests_total` | Counter | `method`, `path`, `status_code` |
|
||||
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `path` |
|
||||
| `agentidp_rate_limit_rejections_total` | Counter | — |
|
||||
| `agentidp_db_query_duration_seconds` | Histogram | `operation` |
|
||||
| `agentidp_redis_command_duration_seconds` | Histogram | `command` |
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `GET /metrics` returns Prometheus text format
|
||||
- [ ] `/metrics` endpoint does NOT require Bearer auth (Prometheus scrapes it)
|
||||
- [ ] All 7 metrics present and updating under load
|
||||
- [ ] Grafana dashboard auto-provisions on `docker compose -f docker-compose.monitoring.yml up`
|
||||
- [ ] Grafana runs on port 3001 (no conflict with AgentIdP on 3000)
|
||||
- [ ] `docs/devops/operations.md` updated with monitoring section
|
||||
- [ ] `prom-client` added as new dependency — CEO approval gate
|
||||
@@ -0,0 +1,37 @@
|
||||
# Spec: OPA Policy Engine Integration
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 5 of 8
|
||||
|
||||
## Scope
|
||||
- New `OpaMiddleware` replacing static scope check in `auth.ts`
|
||||
- `@openpolicyagent/opa-wasm` integration (embedded Wasm, no sidecar)
|
||||
- `policies/authz.rego` — main allow/deny policy
|
||||
- `policies/data/scopes.json` — scope to permission mapping
|
||||
- SIGHUP handler to hot-reload policies without restart
|
||||
- New env var: `POLICY_DIR` (default: `./policies`)
|
||||
|
||||
## Policy interface
|
||||
|
||||
```
|
||||
input = {
|
||||
"method": "GET",
|
||||
"path": "/api/v1/agents",
|
||||
"scopes": ["agents:read"],
|
||||
"agentId": "uuid"
|
||||
}
|
||||
|
||||
output = {
|
||||
"allow": true | false,
|
||||
"reason": "string" // populated when allow=false
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All existing scope checks replaced by OPA evaluation
|
||||
- [ ] Policy files hot-reloadable on SIGHUP (no restart required)
|
||||
- [ ] OPA Wasm loaded at startup — fail-fast if `POLICY_DIR` invalid
|
||||
- [ ] `allow=false` responses return `403` with `reason` in error body
|
||||
- [ ] Existing test suite passes unchanged (OPA evaluates same rules as before)
|
||||
- [ ] New unit tests for OPA middleware: allow/deny cases, missing scope, invalid input
|
||||
- [ ] `POLICY_DIR` env var documented in `docs/devops/environment-variables.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec: Python SDK (`sentryagent-idp`)
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 2 of 8
|
||||
|
||||
## Scope
|
||||
- `sdk-python/` directory at project root
|
||||
- `AgentIdPClient` with sync and async variants
|
||||
- `TokenManager` with 60s auto-refresh
|
||||
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
|
||||
- `AgentIdPError` typed exception
|
||||
- Full type hints — `mypy --strict` clean
|
||||
- `sdk-python/README.md` with installation and usage
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All 14 API endpoints covered
|
||||
- [ ] Sync client: `requests` library
|
||||
- [ ] Async client: `httpx` library
|
||||
- [ ] `mypy --strict` passes with zero errors
|
||||
- [ ] Zero untyped code
|
||||
- [ ] `AgentIdPError` raised (not raw requests/httpx exceptions) on all failure paths
|
||||
- [ ] `TokenManager` tested: caches token, refreshes at exp-60s
|
||||
- [ ] `pyproject.toml` with: name=sentryagent-idp, python>=3.9, dependencies declared
|
||||
- [ ] README matches Node.js SDK structure
|
||||
@@ -0,0 +1,21 @@
|
||||
# Spec: HashiCorp Vault Integration
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 1 of 8
|
||||
|
||||
## Scope
|
||||
- VaultClient class wrapping `node-vault`
|
||||
- `005_add_vault_path.sql` migration
|
||||
- Updated CredentialService to write secrets to Vault instead of PostgreSQL
|
||||
- New env vars: VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
|
||||
- Migration guide: bcrypt → Vault coexistence strategy
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] New credentials: secret written to Vault KV v2, `vault_path` stored in PostgreSQL
|
||||
- [ ] Credential rotation: Vault versioned update, `vault_path` unchanged
|
||||
- [ ] Credential revocation: Vault secret deleted, DB status = `revoked`
|
||||
- [ ] Existing bcrypt credentials continue to work until rotated
|
||||
- [ ] VaultClient follows existing service interface pattern (DRY, SOLID)
|
||||
- [ ] Zero `any` types, TypeScript strict
|
||||
- [ ] `VAULT_ADDR` / `VAULT_TOKEN` validation at startup (fail-fast)
|
||||
- [ ] DevOps docs updated with Vault setup section
|
||||
@@ -0,0 +1,34 @@
|
||||
# Spec: Web Dashboard UI
|
||||
|
||||
**Status**: Pending CEO approval
|
||||
**Workstream**: 6 of 8
|
||||
|
||||
## Scope
|
||||
- `dashboard/` directory at project root
|
||||
- React 18 + TypeScript strict, built with Vite 5
|
||||
- TanStack Query v5 for server state
|
||||
- shadcn/ui (Radix UI + Tailwind CSS) for components
|
||||
- Four pages: Agents, Credentials, Audit Log, Health
|
||||
- Client-side auth: `clientId` + `clientSecret` → `TokenManager`
|
||||
- Served from AgentIdP server at `GET /dashboard` (static build)
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Route | Scope Required |
|
||||
|------|-------|---------------|
|
||||
| Login | `/dashboard/login` | None |
|
||||
| Agents | `/dashboard/agents` | `agents:read` |
|
||||
| Agent Detail | `/dashboard/agents/:id` | `agents:read` |
|
||||
| Credentials | `/dashboard/agents/:id/credentials` | `agents:read` |
|
||||
| Audit Log | `/dashboard/audit` | `audit:read` |
|
||||
| Health | `/dashboard/health` | None |
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] TypeScript strict — zero `any` across all dashboard files
|
||||
- [ ] `dashboard/tsconfig.json` with `strict: true`
|
||||
- [ ] Login form stores token in `sessionStorage` only (not `localStorage`)
|
||||
- [ ] All write operations (suspend, revoke, rotate) require confirmation dialog
|
||||
- [ ] OWASP Top 10 review: no XSS, no CSRF, no sensitive data in URL params
|
||||
- [ ] Vite build outputs to `dashboard/dist/`; AgentIdP serves it as static
|
||||
- [ ] `dashboard/README.md` — how to build and serve
|
||||
- [ ] Responsive layout — functional on desktop and tablet
|
||||
127
openspec/changes/phase-2-production-ready/tasks.md
Normal file
127
openspec/changes/phase-2-production-ready/tasks.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Phase 2: Production-Ready — Tasks
|
||||
|
||||
**Status**: In progress — Workstreams 1, 2, 3, 4 complete.
|
||||
|
||||
## CEO Approval Gates (required before implementation)
|
||||
|
||||
- [x] A0.1 Approve dependency: `node-vault` (Vault integration)
|
||||
- [x] A0.2 Approve dependency: `@openpolicyagent/opa-wasm` (OPA policy engine)
|
||||
- [x] A0.3 Approve dependency: React 18 + Vite 5 (web dashboard)
|
||||
- [x] A0.4 Approve dependency: `prom-client` (Prometheus metrics)
|
||||
- [x] A0.5 Approve dependency: Terraform (infrastructure as code)
|
||||
|
||||
---
|
||||
|
||||
## Workstream 1: HashiCorp Vault Integration
|
||||
|
||||
- [x] 1.1 Write `src/vault/VaultClient.ts` — wraps `node-vault`; methods: writeSecret, readSecret, deleteSecret, verifySecret
|
||||
- [x] 1.2 Write `src/db/migrations/005_add_vault_path.sql` — add `vault_path` column to `credentials`
|
||||
- [x] 1.3 Update `CredentialService.ts` — new credentials use Vault; existing bcrypt credentials continue to work
|
||||
- [x] 1.4 Update `docs/devops/environment-variables.md` — add VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
|
||||
- [x] 1.5 Write `docs/devops/vault-setup.md` — Vault dev server setup, production Vault config, migration guide
|
||||
- [x] 1.6 Write unit tests for VaultClient (mocked Vault) and updated CredentialService
|
||||
- [x] 1.7 QA sign-off: zero `any`, TypeScript strict, >80% coverage, coexistence verified
|
||||
|
||||
## Workstream 2: Python SDK
|
||||
|
||||
- [x] 2.1 Create `sdk-python/` with `pyproject.toml` — name: sentryagent-idp, python>=3.9
|
||||
- [x] 2.2 Write `sdk-python/src/sentryagent_idp/types.py` — all request/response dataclasses
|
||||
- [x] 2.3 Write `sdk-python/src/sentryagent_idp/errors.py` — AgentIdPError exception
|
||||
- [x] 2.4 Write `sdk-python/src/sentryagent_idp/token_manager.py` — sync TokenManager
|
||||
- [x] 2.5 Write `sdk-python/src/sentryagent_idp/async_token_manager.py` — async TokenManager (httpx)
|
||||
- [x] 2.6 Write `sdk-python/src/sentryagent_idp/services/agents.py` — AgentRegistryClient (sync + async)
|
||||
- [x] 2.7 Write `sdk-python/src/sentryagent_idp/services/credentials.py` — CredentialClient (sync + async)
|
||||
- [x] 2.8 Write `sdk-python/src/sentryagent_idp/services/token.py` — TokenClient (sync + async)
|
||||
- [x] 2.9 Write `sdk-python/src/sentryagent_idp/services/audit.py` — AuditClient (sync + async)
|
||||
- [x] 2.10 Write `sdk-python/src/sentryagent_idp/client.py` — AgentIdPClient (sync) + AsyncAgentIdPClient
|
||||
- [x] 2.11 Write `sdk-python/src/sentryagent_idp/__init__.py` — barrel exports
|
||||
- [x] 2.12 Write `sdk-python/README.md`
|
||||
- [x] 2.13 QA: `mypy --strict` clean, all 14 endpoints, AgentIdPError on all failure paths, pytest >80%
|
||||
|
||||
## Workstream 3: Go SDK
|
||||
|
||||
- [x] 3.1 Create `sdk-go/` with `go.mod` — module: github.com/sentryagent/idp-sdk-go, go 1.21
|
||||
- [x] 3.2 Write `sdk-go/types.go` — all request/response structs
|
||||
- [x] 3.3 Write `sdk-go/errors.go` — AgentIdPError type implementing error interface
|
||||
- [x] 3.4 Write `sdk-go/token_manager.go` — mutex-guarded TokenManager
|
||||
- [x] 3.5 Write `sdk-go/agents.go` — AgentRegistryClient (flat package; see ADR below)
|
||||
- [x] 3.6 Write `sdk-go/credentials.go` — CredentialClient
|
||||
- [x] 3.7 Write `sdk-go/token_service.go` — TokenServiceClient
|
||||
- [x] 3.8 Write `sdk-go/audit.go` — AuditClient
|
||||
- [x] 3.9 Write `sdk-go/client.go` — AgentIdPClient
|
||||
- [x] 3.10 Write `sdk-go/README.md`
|
||||
- [x] 3.11 QA: `go vet` clean, `staticcheck` clean, all 14 endpoints, goroutine-safe, `go test ./...` >80%
|
||||
|
||||
## Workstream 4: Java SDK
|
||||
|
||||
- [x] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17
|
||||
- [x] 4.2 Write all POJO request/response model classes
|
||||
- [x] 4.3 Write `AgentIdPException.java` extending RuntimeException
|
||||
- [x] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer
|
||||
- [x] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.9 Write `AgentIdPClient.java` — composes all service clients
|
||||
- [x] 4.10 Write `sdk-java/README.md`
|
||||
- [x] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80%
|
||||
|
||||
## Workstream 5: OPA Policy Engine
|
||||
|
||||
- [x] 5.1 Write `policies/authz.rego` — allow/deny rules matching all current scope checks
|
||||
- [x] 5.2 Write `policies/data/scopes.json` — scope to endpoint permission mapping
|
||||
- [x] 5.3 Write `src/middleware/opa.ts` — OpaMiddleware: loads Wasm, evaluates input, returns allow/deny
|
||||
- [x] 5.4 Replace static scope check in `src/middleware/auth.ts` with OpaMiddleware
|
||||
- [x] 5.5 Add SIGHUP handler in `src/server.ts` to hot-reload policy files
|
||||
- [x] 5.6 Update `docs/devops/environment-variables.md` — add POLICY_DIR
|
||||
- [x] 5.7 QA: all existing auth tests pass unchanged, new OPA unit tests, hot-reload verified
|
||||
|
||||
## Workstream 6: Web Dashboard UI
|
||||
|
||||
- [x] 6.1 Create `dashboard/` with Vite 5 + React 18 + TypeScript strict configuration
|
||||
- [x] 6.2 Set up shadcn/ui with Tailwind CSS
|
||||
- [x] 6.3 Write `dashboard/src/lib/auth.ts` — credential entry, TokenManager, sessionStorage
|
||||
- [x] 6.4 Write `dashboard/src/lib/client.ts` — wraps @sentryagent/idp-sdk AgentIdPClient
|
||||
- [x] 6.5 Write Login page (`/dashboard/login`)
|
||||
- [x] 6.6 Write Agents page (`/dashboard/agents`) — list, search, filter by status
|
||||
- [x] 6.7 Write Agent Detail page (`/dashboard/agents/:id`) — suspend/reactivate with confirm dialog
|
||||
- [x] 6.8 Write Credentials page (`/dashboard/agents/:id/credentials`) — rotate/revoke with confirm
|
||||
- [x] 6.9 Write Audit Log page (`/dashboard/audit`) — filters, pagination
|
||||
- [x] 6.10 Write Health page (`/dashboard/health`) — PostgreSQL + Redis connectivity status
|
||||
- [x] 6.11 Configure AgentIdP Express app to serve `dashboard/dist/` at `/dashboard`
|
||||
- [x] 6.12 Write `dashboard/README.md`
|
||||
- [x] 6.13 QA: TypeScript strict, zero `any`, OWASP Top 10 review, responsive layout verified
|
||||
|
||||
## Workstream 7: Prometheus + Grafana Monitoring
|
||||
|
||||
- [x] 7.1 Add `prom-client` to dependencies (after CEO approval A0.4)
|
||||
- [x] 7.2 Write `src/metrics/registry.ts` — shared Prometheus Registry with all 7 metric definitions
|
||||
- [x] 7.3 Instrument `OAuth2Service.ts` — increment `agentidp_tokens_issued_total`
|
||||
- [x] 7.4 Instrument `AgentService.ts` — increment `agentidp_agents_registered_total`
|
||||
- [x] 7.5 Instrument `src/middleware/` — HTTP request counter and duration histogram
|
||||
- [x] 7.6 Instrument `src/db/pool.ts` — DB query duration histogram
|
||||
- [x] 7.7 Instrument `src/cache/redis.ts` — Redis command duration histogram
|
||||
- [x] 7.8 Add `GET /metrics` route (unauthenticated, Prometheus text format)
|
||||
- [x] 7.9 Write `monitoring/prometheus/prometheus.yml` — scrape config
|
||||
- [x] 7.10 Write `monitoring/grafana/provisioning/` — datasource + dashboard provisioning
|
||||
- [x] 7.11 Write `monitoring/grafana/dashboards/agentidp.json` — pre-built Grafana dashboard
|
||||
- [x] 7.12 Write `docker-compose.monitoring.yml` overlay
|
||||
- [x] 7.13 Update `docs/devops/operations.md` — monitoring section
|
||||
- [x] 7.14 QA: all 7 metrics verified under load, Grafana auto-provisions, no auth leak on /metrics
|
||||
|
||||
## Workstream 8: Multi-Region Deployment (Terraform)
|
||||
|
||||
- [x] 8.1 Write `terraform/modules/agentidp/main.tf` + `variables.tf` + `outputs.tf`
|
||||
- [x] 8.2 Write `terraform/modules/rds/` — managed PostgreSQL module
|
||||
- [x] 8.3 Write `terraform/modules/redis/` — managed Redis module
|
||||
- [x] 8.4 Write `terraform/modules/lb/` — load balancer + TLS module
|
||||
- [x] 8.5 Write `terraform/environments/aws/main.tf` + `variables.tf` + `terraform.tfvars.example`
|
||||
- [x] 8.6 Write `terraform/environments/gcp/main.tf` + `variables.tf` + `terraform.tfvars.example`
|
||||
- [x] 8.7 Write `docs/devops/deployment.md` — end-to-end AWS and GCP deployment walkthrough
|
||||
- [x] 8.8 QA: secrets not hardcoded, TLS enforced, DB/Redis VPC-internal (static review passed; terraform validate requires Terraform CLI not present in this env)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Complete Criteria
|
||||
|
||||
All 8 workstreams done. All tasks checked. All QA gates passed. CEO reviewed.
|
||||
218
package-lock.json
generated
218
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "sentryagent-idp",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@open-policy-agent/opa-wasm": "^1.10.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -16,9 +17,11 @@
|
||||
"joi": "^17.12.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"node-vault": "^0.12.0",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^8.19.0",
|
||||
"pino-http": "^9.0.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"redis": "^4.6.13",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@@ -30,6 +33,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/node-vault": "^0.9.1",
|
||||
"@types/pg": "^8.11.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
@@ -1261,6 +1265,31 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@open-policy-agent/opa-wasm": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-policy-agent/opa-wasm/-/opa-wasm-1.10.0.tgz",
|
||||
"integrity": "sha512-ymR/nFS3nO9o24j9xowGGQaf+Gmb813QcxUpVZkfRlJkawKWqSIllnEH15agyWjijmOIyhA+OBErenx6N3jphw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"sprintf-js": "^1.1.2",
|
||||
"yaml": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@open-policy-agent/opa-wasm/node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -1475,6 +1504,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/caseless": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
|
||||
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -1625,6 +1661,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mustache": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz",
|
||||
"integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
@@ -1635,6 +1678,17 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-vault": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-vault/-/node-vault-0.9.1.tgz",
|
||||
"integrity": "sha512-h7b0JZ76kvwFL/XvfNV2LJ45/SVXLkOvrIKHIGR5Cp3c/BIWsDetJR6Gfzppl3BfX5RN3rlEuHmmHhKnuL4nlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mustache": "*",
|
||||
"@types/request": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -1661,6 +1715,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/request": {
|
||||
"version": "2.48.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
|
||||
"integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/caseless": "*",
|
||||
"@types/node": "*",
|
||||
"@types/tough-cookie": "*",
|
||||
"form-data": "^2.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/request/node_modules/form-data": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
|
||||
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -1725,6 +1810,13 @@
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
@@ -2137,7 +2229,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
@@ -2149,6 +2240,17 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -2339,6 +2441,12 @@
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bintrees": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
|
||||
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -2690,7 +2798,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -2831,7 +2938,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -2881,7 +2987,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -3094,7 +3199,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3647,11 +3751,30 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -3987,7 +4110,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -5414,6 +5536,15 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mustache": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mustache": "bin/mustache"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -5451,6 +5582,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-vault": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.12.0.tgz",
|
||||
"integrity": "sha512-+SL3DSREptI+UJMM8UUwlI3jR5agPuAgCxSdUfeybGKszXiILXTCUHxErDdpgNgug8oj4v2rOmyrXhRJ4LZsyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"debug": "^4.3.4",
|
||||
"mustache": "^4.2.0",
|
||||
"tv4": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -6051,6 +6197,19 @@
|
||||
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prom-client": {
|
||||
"version": "15.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
|
||||
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"tdigest": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16 || ^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -6078,6 +6237,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6811,6 +6979,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tdigest": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
|
||||
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bintrees": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
@@ -7018,6 +7195,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tv4": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
|
||||
"integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
|
||||
"license": [
|
||||
{
|
||||
"type": "Public Domain",
|
||||
"url": "http://geraintluff.github.io/tv4/LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"type": "MIT",
|
||||
"url": "http://jsonary.com/LICENSE.txt"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -7313,6 +7508,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"format": "prettier --write src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-policy-agent/opa-wasm": "^1.10.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -23,9 +24,11 @@
|
||||
"joi": "^17.12.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"node-vault": "^0.12.0",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^8.19.0",
|
||||
"pino-http": "^9.0.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"redis": "^4.6.13",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@@ -37,6 +40,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/node-vault": "^0.9.1",
|
||||
"@types/pg": "^8.11.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
|
||||
86
policies/authz.rego
Normal file
86
policies/authz.rego
Normal file
@@ -0,0 +1,86 @@
|
||||
package authz
|
||||
|
||||
import rego.v1
|
||||
|
||||
# ─── Data ─────────────────────────────────────────────────────────────────────
|
||||
# data.endpoint_permissions is loaded from policies/data/scopes.json
|
||||
# Structure: { "METHOD:/path/pattern": ["scope1", ...], ... }
|
||||
|
||||
# ─── Default ──────────────────────────────────────────────────────────────────
|
||||
default allow := false
|
||||
|
||||
default reason := "insufficient_scope"
|
||||
|
||||
# ─── Path pattern normalisation ───────────────────────────────────────────────
|
||||
# Converts a concrete request path to a pattern key by replacing UUID-like
|
||||
# segments with named placeholders.
|
||||
#
|
||||
# Supported patterns (longest-match wins via iteration):
|
||||
# /api/v1/agents/{uuid}/credentials/{uuid}/rotate
|
||||
# /api/v1/agents/{uuid}/credentials/{uuid}
|
||||
# /api/v1/agents/{uuid}/credentials
|
||||
# /api/v1/agents/{uuid}
|
||||
# /api/v1/agents
|
||||
# /api/v1/token/introspect
|
||||
# /api/v1/token/revoke
|
||||
# /api/v1/audit/{uuid}
|
||||
# /api/v1/audit
|
||||
|
||||
# Build the lookup key from method + normalised path.
|
||||
lookup_key(method, path) := key if {
|
||||
normalised := normalise_path(path)
|
||||
key := concat(":", [method, normalised])
|
||||
}
|
||||
|
||||
# Normalise a concrete path to its pattern form.
|
||||
normalise_path(path) := "/api/v1/agents/:id/credentials/:credId/rotate" if {
|
||||
regex.match(`^/api/v1/agents/[^/]+/credentials/[^/]+/rotate$`, path)
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/agents/:id/credentials/:credId" if {
|
||||
regex.match(`^/api/v1/agents/[^/]+/credentials/[^/]+$`, path)
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/agents/:id/credentials" if {
|
||||
regex.match(`^/api/v1/agents/[^/]+/credentials$`, path)
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/agents/:id" if {
|
||||
regex.match(`^/api/v1/agents/[^/]+$`, path)
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/agents" if {
|
||||
path == "/api/v1/agents"
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/token/introspect" if {
|
||||
path == "/api/v1/token/introspect"
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/token/revoke" if {
|
||||
path == "/api/v1/token/revoke"
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/audit/:id" if {
|
||||
regex.match(`^/api/v1/audit/[^/]+$`, path)
|
||||
}
|
||||
|
||||
normalise_path(path) := "/api/v1/audit" if {
|
||||
path == "/api/v1/audit"
|
||||
}
|
||||
|
||||
# ─── Core allow rule ──────────────────────────────────────────────────────────
|
||||
# allow = true if every required scope for the endpoint is present in input.scopes.
|
||||
|
||||
allow if {
|
||||
key := lookup_key(input.method, input.path)
|
||||
required := data.endpoint_permissions[key]
|
||||
every req_scope in required {
|
||||
req_scope in input.scopes
|
||||
}
|
||||
}
|
||||
|
||||
# reason is populated only on deny.
|
||||
reason := "missing required scope for this endpoint" if {
|
||||
not allow
|
||||
}
|
||||
17
policies/data/scopes.json
Normal file
17
policies/data/scopes.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"endpoint_permissions": {
|
||||
"GET:/api/v1/agents": ["agents:read"],
|
||||
"GET:/api/v1/agents/:id": ["agents:read"],
|
||||
"POST:/api/v1/agents": ["agents:write"],
|
||||
"PATCH:/api/v1/agents/:id": ["agents:write"],
|
||||
"DELETE:/api/v1/agents/:id": ["agents:write"],
|
||||
"GET:/api/v1/agents/:id/credentials": ["agents:read"],
|
||||
"POST:/api/v1/agents/:id/credentials": ["agents:write"],
|
||||
"POST:/api/v1/agents/:id/credentials/:credId/rotate": ["agents:write"],
|
||||
"DELETE:/api/v1/agents/:id/credentials/:credId": ["agents:write"],
|
||||
"POST:/api/v1/token/introspect": ["tokens:read"],
|
||||
"POST:/api/v1/token/revoke": ["tokens:read"],
|
||||
"GET:/api/v1/audit": ["audit:read"],
|
||||
"GET:/api/v1/audit/:id": ["audit:read"]
|
||||
}
|
||||
}
|
||||
200
sdk-go/README.md
Normal file
200
sdk-go/README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# SentryAgent.ai AgentIdP — Go SDK
|
||||
|
||||
Official Go client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.21+
|
||||
- A running AgentIdP server
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/sentryagent/idp-sdk-go
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
agentidp "github.com/sentryagent/idp-sdk-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
|
||||
BaseURL: "https://idp.example.com",
|
||||
ClientID: "your-agent-client-id",
|
||||
ClientSecret: "sk_live_...",
|
||||
})
|
||||
|
||||
// Register a new AI agent
|
||||
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{
|
||||
Email: "screener@example.com",
|
||||
AgentType: "screener",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []string{"read", "classify"},
|
||||
Owner: "platform-team",
|
||||
DeploymentEnv: "production",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Registered agent: %s\n", agent.AgentID)
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The SDK handles OAuth 2.0 Client Credentials automatically. Tokens are cached and refreshed 60 seconds before expiry. All operations are goroutine-safe.
|
||||
|
||||
```go
|
||||
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
|
||||
BaseURL: "https://idp.example.com",
|
||||
ClientID: "my-client-id",
|
||||
ClientSecret: "my-client-secret",
|
||||
Scope: "agents:read agents:write", // optional, defaults to all four scopes
|
||||
})
|
||||
```
|
||||
|
||||
## Agent Registry
|
||||
|
||||
```go
|
||||
ctx := context.Background()
|
||||
|
||||
// Register
|
||||
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{...})
|
||||
|
||||
// List (with optional filters)
|
||||
agents, err := client.Agents.ListAgents(ctx, &agentidp.ListAgentsParams{
|
||||
Status: "active",
|
||||
AgentType: "screener",
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
})
|
||||
|
||||
// Get by ID
|
||||
agent, err := client.Agents.GetAgent(ctx, "agent-uuid")
|
||||
|
||||
// Partial update
|
||||
version := "2.0.0"
|
||||
agent, err := client.Agents.UpdateAgent(ctx, "agent-uuid", agentidp.UpdateAgentRequest{
|
||||
Version: &version,
|
||||
})
|
||||
|
||||
// Decommission (permanent)
|
||||
err = client.Agents.DecommissionAgent(ctx, "agent-uuid")
|
||||
```
|
||||
|
||||
## Credential Management
|
||||
|
||||
```go
|
||||
// Generate (returns one-time ClientSecret)
|
||||
cred, err := client.Credentials.GenerateCredential(ctx, "agent-uuid")
|
||||
fmt.Println(cred.ClientSecret) // store this — it is never shown again
|
||||
|
||||
// List
|
||||
creds, err := client.Credentials.ListCredentials(ctx, "agent-uuid", 1, 20)
|
||||
|
||||
// Rotate (old secret is immediately invalidated)
|
||||
newCred, err := client.Credentials.RotateCredential(ctx, "agent-uuid", "cred-uuid")
|
||||
|
||||
// Revoke
|
||||
revoked, err := client.Credentials.RevokeCredential(ctx, "agent-uuid", "cred-uuid")
|
||||
```
|
||||
|
||||
## Token Operations
|
||||
|
||||
```go
|
||||
// Introspect (RFC 7662)
|
||||
result, err := client.Tokens.IntrospectToken(ctx, "access-token-to-check")
|
||||
if result.Active {
|
||||
fmt.Printf("Token belongs to: %s\n", *result.Sub)
|
||||
}
|
||||
|
||||
// Revoke
|
||||
err = client.Tokens.RevokeToken(ctx, "access-token-to-revoke")
|
||||
```
|
||||
|
||||
## Audit Log
|
||||
|
||||
```go
|
||||
// Query with filters
|
||||
events, err := client.Audit.QueryAuditLog(ctx, &agentidp.QueryAuditParams{
|
||||
AgentID: "agent-uuid",
|
||||
Action: "token.issued",
|
||||
Outcome: "success",
|
||||
FromDate: "2026-01-01",
|
||||
ToDate: "2026-01-31",
|
||||
Page: 1,
|
||||
Limit: 50,
|
||||
})
|
||||
|
||||
// Get single event
|
||||
event, err := client.Audit.GetAuditEvent(ctx, "event-uuid")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are returned as `*AgentIdPError`:
|
||||
|
||||
```go
|
||||
agent, err := client.Agents.GetAgent(ctx, "unknown-id")
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*agentidp.AgentIdPError); ok {
|
||||
fmt.Printf("code=%s status=%d\n", apiErr.Code, apiErr.HTTPStatus)
|
||||
// e.g. code=AgentNotFoundError status=404
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------------------------|-------------------------------------------------|
|
||||
| `Code` | `string` | Machine-readable error code |
|
||||
| `Message` | `string` | Human-readable description |
|
||||
| `HTTPStatus` | `int` | HTTP status code (0 for network/build errors) |
|
||||
| `Details` | `map[string]interface{}` | Optional structured context from the API |
|
||||
|
||||
## Custom HTTP Client
|
||||
|
||||
Inject a custom `*http.Client` for proxy support, custom timeouts, or test mocking:
|
||||
|
||||
```go
|
||||
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
|
||||
BaseURL: "https://idp.example.com",
|
||||
ClientID: "cid",
|
||||
ClientSecret: "secret",
|
||||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
||||
})
|
||||
```
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Endpoint | Method | SDK Method |
|
||||
|--------------------------------------------------|--------|-----------------------------------------|
|
||||
| POST /api/v1/agents | POST | `Agents.RegisterAgent` |
|
||||
| GET /api/v1/agents | GET | `Agents.ListAgents` |
|
||||
| GET /api/v1/agents/:id | GET | `Agents.GetAgent` |
|
||||
| PATCH /api/v1/agents/:id | PATCH | `Agents.UpdateAgent` |
|
||||
| DELETE /api/v1/agents/:id | DELETE | `Agents.DecommissionAgent` |
|
||||
| POST /api/v1/agents/:id/credentials | POST | `Credentials.GenerateCredential` |
|
||||
| GET /api/v1/agents/:id/credentials | GET | `Credentials.ListCredentials` |
|
||||
| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `Credentials.RotateCredential` |
|
||||
| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `Credentials.RevokeCredential` |
|
||||
| POST /api/v1/token | POST | (TokenManager — automatic) |
|
||||
| POST /api/v1/token/introspect | POST | `Tokens.IntrospectToken` |
|
||||
| POST /api/v1/token/revoke | POST | `Tokens.RevokeToken` |
|
||||
| GET /api/v1/audit | GET | `Audit.QueryAuditLog` |
|
||||
| GET /api/v1/audit/:id | GET | `Audit.GetAuditEvent` |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see [LICENSE](../LICENSE).
|
||||
113
sdk-go/agents.go
Normal file
113
sdk-go/agents.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentRegistryClient provides methods for the Agent Registry API endpoints.
|
||||
// All methods take a context.Context as first argument.
|
||||
type AgentRegistryClient struct {
|
||||
baseURL string
|
||||
getToken func(ctx context.Context) (string, error)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAgentRegistryClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AgentRegistryClient {
|
||||
return &AgentRegistryClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
getToken: getToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAgent registers a new AI agent identity.
|
||||
// POST /api/v1/agents → 201 Agent
|
||||
func (c *AgentRegistryClient) RegisterAgent(ctx context.Context, req RegisterAgentRequest) (*Agent, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var agent Agent
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents", req, token, &agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// ListAgents returns a paginated list of registered agents.
|
||||
// GET /api/v1/agents → 200 PaginatedAgents
|
||||
func (c *AgentRegistryClient) ListAgents(ctx context.Context, params *ListAgentsParams) (*PaginatedAgents, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL := c.baseURL + "/api/v1/agents"
|
||||
if params != nil {
|
||||
q := url.Values{}
|
||||
if params.Status != "" {
|
||||
q.Set("status", params.Status)
|
||||
}
|
||||
if params.AgentType != "" {
|
||||
q.Set("agentType", params.AgentType)
|
||||
}
|
||||
if params.DeploymentEnv != "" {
|
||||
q.Set("deploymentEnv", params.DeploymentEnv)
|
||||
}
|
||||
if params.Page > 0 {
|
||||
q.Set("page", fmt.Sprintf("%d", params.Page))
|
||||
}
|
||||
if params.Limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", params.Limit))
|
||||
}
|
||||
if len(q) > 0 {
|
||||
rawURL += "?" + q.Encode()
|
||||
}
|
||||
}
|
||||
var result PaginatedAgents
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAgent retrieves a single agent by ID.
|
||||
// GET /api/v1/agents/:id → 200 Agent
|
||||
func (c *AgentRegistryClient) GetAgent(ctx context.Context, agentID string) (*Agent, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var agent Agent
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/agents/"+agentID, nil, token, &agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// UpdateAgent partially updates an agent.
|
||||
// PATCH /api/v1/agents/:id → 200 Agent
|
||||
func (c *AgentRegistryClient) UpdateAgent(ctx context.Context, agentID string, req UpdateAgentRequest) (*Agent, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var agent Agent
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodPatch, c.baseURL+"/api/v1/agents/"+agentID, req, token, &agent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// DecommissionAgent permanently removes an agent.
|
||||
// DELETE /api/v1/agents/:id → 204 No Content
|
||||
func (c *AgentRegistryClient) DecommissionAgent(ctx context.Context, agentID string) error {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doRequest(ctx, c.httpClient, http.MethodDelete, c.baseURL+"/api/v1/agents/"+agentID, nil, token, nil)
|
||||
}
|
||||
181
sdk-go/agents_test.go
Normal file
181
sdk-go/agents_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockAgent is the canonical test agent fixture.
|
||||
var mockAgent = Agent{
|
||||
AgentID: "uuid-1",
|
||||
Email: "a@b.ai",
|
||||
AgentType: "screener",
|
||||
Version: "1.0.0",
|
||||
Capabilities: []string{"read"},
|
||||
Owner: "team",
|
||||
DeploymentEnv: "production",
|
||||
Status: "active",
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
UpdatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
var mockPaginatedAgents = PaginatedAgents{
|
||||
Data: []Agent{mockAgent},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
}
|
||||
|
||||
// staticToken returns a fixed token for all test service clients.
|
||||
func staticToken(_ context.Context) (string, error) {
|
||||
return "test-bearer-token", nil
|
||||
}
|
||||
|
||||
func newAgentServer(t *testing.T, method, path string, status int, body interface{}) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != method {
|
||||
t.Errorf("expected method %s, got %s", method, r.Method)
|
||||
}
|
||||
if r.URL.Path != path {
|
||||
t.Errorf("expected path %s, got %s", path, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
t.Error("missing Authorization header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_RegisterAgent(t *testing.T) {
|
||||
srv := newAgentServer(t, http.MethodPost, "/api/v1/agents", 201, mockAgent)
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
agent, err := client.RegisterAgent(context.Background(), RegisterAgentRequest{
|
||||
Email: "a@b.ai", AgentType: "screener", Version: "1.0.0",
|
||||
Capabilities: []string{"read"}, Owner: "team", DeploymentEnv: "production",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if agent.AgentID != "uuid-1" {
|
||||
t.Errorf("expected uuid-1, got %q", agent.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_ListAgents(t *testing.T) {
|
||||
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents", 200, mockPaginatedAgents)
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
result, err := client.ListAgents(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", result.Total)
|
||||
}
|
||||
if len(result.Data) != 1 || result.Data[0].AgentID != "uuid-1" {
|
||||
t.Error("unexpected data in paginated result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_ListAgents_WithParams(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("status") != "active" {
|
||||
t.Errorf("expected status=active, got %q", r.URL.Query().Get("status"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockPaginatedAgents)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.ListAgents(context.Background(), &ListAgentsParams{Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_GetAgent(t *testing.T) {
|
||||
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents/uuid-1", 200, mockAgent)
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
agent, err := client.GetAgent(context.Background(), "uuid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if agent.AgentID != "uuid-1" {
|
||||
t.Errorf("expected uuid-1, got %q", agent.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_GetAgent_NotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(404)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"code": "AgentNotFoundError",
|
||||
"message": "Agent not found.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.GetAgent(context.Background(), "bad-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*AgentIdPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AgentIdPError, got %T", err)
|
||||
}
|
||||
if apiErr.Code != "AgentNotFoundError" {
|
||||
t.Errorf("expected AgentNotFoundError, got %q", apiErr.Code)
|
||||
}
|
||||
if apiErr.HTTPStatus != 404 {
|
||||
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_UpdateAgent(t *testing.T) {
|
||||
updated := mockAgent
|
||||
updated.Version = "2.0.0"
|
||||
srv := newAgentServer(t, http.MethodPatch, "/api/v1/agents/uuid-1", 200, updated)
|
||||
defer srv.Close()
|
||||
|
||||
v := "2.0.0"
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
agent, err := client.UpdateAgent(context.Background(), "uuid-1", UpdateAgentRequest{Version: &v})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if agent.Version != "2.0.0" {
|
||||
t.Errorf("expected version 2.0.0, got %q", agent.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryClient_DecommissionAgent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
|
||||
err := client.DecommissionAgent(context.Background(), "uuid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
80
sdk-go/audit.go
Normal file
80
sdk-go/audit.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AuditClient provides methods for querying the Audit Log API endpoints.
|
||||
type AuditClient struct {
|
||||
baseURL string
|
||||
getToken func(ctx context.Context) (string, error)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAuditClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AuditClient {
|
||||
return &AuditClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
getToken: getToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryAuditLog returns a filtered, paginated list of audit events.
|
||||
// GET /api/v1/audit → 200 PaginatedAuditEvents
|
||||
func (c *AuditClient) QueryAuditLog(ctx context.Context, params *QueryAuditParams) (*PaginatedAuditEvents, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL := c.baseURL + "/api/v1/audit"
|
||||
if params != nil {
|
||||
q := url.Values{}
|
||||
if params.AgentID != "" {
|
||||
q.Set("agentId", params.AgentID)
|
||||
}
|
||||
if params.Action != "" {
|
||||
q.Set("action", params.Action)
|
||||
}
|
||||
if params.Outcome != "" {
|
||||
q.Set("outcome", params.Outcome)
|
||||
}
|
||||
if params.FromDate != "" {
|
||||
q.Set("fromDate", params.FromDate)
|
||||
}
|
||||
if params.ToDate != "" {
|
||||
q.Set("toDate", params.ToDate)
|
||||
}
|
||||
if params.Page > 0 {
|
||||
q.Set("page", fmt.Sprintf("%d", params.Page))
|
||||
}
|
||||
if params.Limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", params.Limit))
|
||||
}
|
||||
if len(q) > 0 {
|
||||
rawURL += "?" + q.Encode()
|
||||
}
|
||||
}
|
||||
var result PaginatedAuditEvents
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAuditEvent retrieves a single audit event by ID.
|
||||
// GET /api/v1/audit/:id → 200 AuditEvent
|
||||
func (c *AuditClient) GetAuditEvent(ctx context.Context, eventID string) (*AuditEvent, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var event AuditEvent
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/audit/"+eventID, nil, token, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
126
sdk-go/audit_test.go
Normal file
126
sdk-go/audit_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var mockAuditEvent = AuditEvent{
|
||||
EventID: "ev-1",
|
||||
AgentID: "uuid-1",
|
||||
Action: "token.issued",
|
||||
Outcome: "success",
|
||||
IPAddress: "1.2.3.4",
|
||||
UserAgent: "curl",
|
||||
Metadata: map[string]interface{}{},
|
||||
Timestamp: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
var mockPaginatedAudit = PaginatedAuditEvents{
|
||||
Data: []AuditEvent{mockAuditEvent},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
}
|
||||
|
||||
func TestAuditClient_QueryAuditLog(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAuditClient(srv.URL, staticToken, &http.Client{})
|
||||
result, err := client.QueryAuditLog(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", result.Total)
|
||||
}
|
||||
if len(result.Data) == 0 || result.Data[0].EventID != "ev-1" {
|
||||
t.Error("unexpected data in paginated result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditClient_QueryAuditLog_WithParams(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("agentId") != "uuid-1" {
|
||||
t.Errorf("expected agentId=uuid-1, got %q", q.Get("agentId"))
|
||||
}
|
||||
if q.Get("action") != "token.issued" {
|
||||
t.Errorf("expected action=token.issued, got %q", q.Get("action"))
|
||||
}
|
||||
if q.Get("fromDate") != "2026-01-01" {
|
||||
t.Errorf("expected fromDate=2026-01-01, got %q", q.Get("fromDate"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAuditClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.QueryAuditLog(context.Background(), &QueryAuditParams{
|
||||
AgentID: "uuid-1",
|
||||
Action: "token.issued",
|
||||
FromDate: "2026-01-01",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditClient_GetAuditEvent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit/ev-1" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockAuditEvent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAuditClient(srv.URL, staticToken, &http.Client{})
|
||||
event, err := client.GetAuditEvent(context.Background(), "ev-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if event.EventID != "ev-1" {
|
||||
t.Errorf("expected ev-1, got %q", event.EventID)
|
||||
}
|
||||
if event.Action != "token.issued" {
|
||||
t.Errorf("expected token.issued, got %q", event.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditClient_Error_Propagated(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(404)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"code": "AuditEventNotFoundError",
|
||||
"message": "Event not found.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newAuditClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.GetAuditEvent(context.Background(), "bad-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*AgentIdPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AgentIdPError, got %T", err)
|
||||
}
|
||||
if apiErr.Code != "AuditEventNotFoundError" {
|
||||
t.Errorf("expected AuditEventNotFoundError, got %q", apiErr.Code)
|
||||
}
|
||||
}
|
||||
83
sdk-go/client.go
Normal file
83
sdk-go/client.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentIdPClientConfig holds all configuration for AgentIdPClient.
|
||||
type AgentIdPClientConfig struct {
|
||||
// BaseURL is the root URL of the AgentIdP server (e.g. "https://idp.example.com").
|
||||
BaseURL string
|
||||
// ClientID is the agent's OAuth 2.0 client ID.
|
||||
ClientID string
|
||||
// ClientSecret is the agent's OAuth 2.0 client secret.
|
||||
ClientSecret string
|
||||
// Scope is the space-separated list of OAuth 2.0 scopes to request.
|
||||
// Defaults to all four scopes when empty.
|
||||
Scope string
|
||||
// HTTPClient allows injecting a custom *http.Client (e.g. for testing).
|
||||
// When nil, a default client with a 30-second timeout is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// AgentIdPClient is the top-level client for the SentryAgent.ai AgentIdP API.
|
||||
// It composes all four service clients and manages token acquisition automatically.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
|
||||
// BaseURL: "https://idp.example.com",
|
||||
// ClientID: "my-agent-id",
|
||||
// ClientSecret: "sk_live_...",
|
||||
// })
|
||||
// agent, err := client.Agents.GetAgent(ctx, "uuid-1")
|
||||
type AgentIdPClient struct {
|
||||
// Agents provides access to the Agent Registry endpoints.
|
||||
Agents *AgentRegistryClient
|
||||
// Credentials provides access to the Credential Management endpoints.
|
||||
Credentials *CredentialClient
|
||||
// Tokens provides access to the Token introspection and revocation endpoints.
|
||||
Tokens *TokenServiceClient
|
||||
// Audit provides access to the Audit Log endpoints.
|
||||
Audit *AuditClient
|
||||
|
||||
tokenManager *TokenManager
|
||||
}
|
||||
|
||||
// NewAgentIdPClient creates a new AgentIdPClient with the given configuration.
|
||||
func NewAgentIdPClient(cfg AgentIdPClientConfig) *AgentIdPClient {
|
||||
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||
|
||||
scope := cfg.Scope
|
||||
if scope == "" {
|
||||
scope = "agents:read agents:write tokens:read audit:read"
|
||||
}
|
||||
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
tm := NewTokenManager(baseURL, cfg.ClientID, cfg.ClientSecret, scope)
|
||||
|
||||
getToken := func(ctx context.Context) (string, error) {
|
||||
return tm.GetToken(ctx)
|
||||
}
|
||||
|
||||
return &AgentIdPClient{
|
||||
Agents: newAgentRegistryClient(baseURL, getToken, httpClient),
|
||||
Credentials: newCredentialClient(baseURL, getToken, httpClient),
|
||||
Tokens: newTokenServiceClient(baseURL, getToken, httpClient),
|
||||
Audit: newAuditClient(baseURL, getToken, httpClient),
|
||||
tokenManager: tm,
|
||||
}
|
||||
}
|
||||
|
||||
// ClearTokenCache invalidates the cached access token.
|
||||
// The next API call will fetch a fresh token from the server.
|
||||
func (c *AgentIdPClient) ClearTokenCache() {
|
||||
c.tokenManager.ClearCache()
|
||||
}
|
||||
124
sdk-go/client_test.go
Normal file
124
sdk-go/client_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// integrationServer returns a minimal mock server that handles the token endpoint
|
||||
// plus a provided handler for all other routes.
|
||||
func integrationServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "integration-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read agents:write tokens:read audit:read",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/", handler)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestNewAgentIdPClient_GetAgent(t *testing.T) {
|
||||
srv := integrationServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/agents/") {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer integration-token" {
|
||||
t.Errorf("unexpected Authorization: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockAgent)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
client := NewAgentIdPClient(AgentIdPClientConfig{
|
||||
BaseURL: srv.URL,
|
||||
ClientID: "cid",
|
||||
ClientSecret: "secret",
|
||||
})
|
||||
|
||||
agent, err := client.Agents.GetAgent(context.Background(), "uuid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if agent.AgentID != "uuid-1" {
|
||||
t.Errorf("expected uuid-1, got %q", agent.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentIdPClient_ClearTokenCache(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/token" {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "tok",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockAgent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewAgentIdPClient(AgentIdPClientConfig{
|
||||
BaseURL: srv.URL,
|
||||
ClientID: "cid",
|
||||
ClientSecret: "secret",
|
||||
})
|
||||
|
||||
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
|
||||
client.ClearTokenCache()
|
||||
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
|
||||
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected 2 token fetches after ClearTokenCache, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentIdPClient_DefaultScope(t *testing.T) {
|
||||
var capturedScope string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/token" {
|
||||
_ = r.ParseForm()
|
||||
capturedScope = r.FormValue("scope")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "tok",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": capturedScope,
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockAgent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewAgentIdPClient(AgentIdPClientConfig{
|
||||
BaseURL: srv.URL,
|
||||
ClientID: "cid",
|
||||
ClientSecret: "secret",
|
||||
// Scope intentionally omitted → defaults applied
|
||||
})
|
||||
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
|
||||
|
||||
expected := "agents:read agents:write tokens:read audit:read"
|
||||
if capturedScope != expected {
|
||||
t.Errorf("expected scope %q, got %q", expected, capturedScope)
|
||||
}
|
||||
}
|
||||
93
sdk-go/credentials.go
Normal file
93
sdk-go/credentials.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CredentialClient provides methods for the Credential Management API endpoints.
|
||||
type CredentialClient struct {
|
||||
baseURL string
|
||||
getToken func(ctx context.Context) (string, error)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newCredentialClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *CredentialClient {
|
||||
return &CredentialClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
getToken: getToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateCredential creates a new credential for the given agent.
|
||||
// POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret
|
||||
func (c *CredentialClient) GenerateCredential(ctx context.Context, agentID string) (*CredentialWithSecret, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cred CredentialWithSecret
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents/"+agentID+"/credentials", struct{}{}, token, &cred); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
// ListCredentials returns a paginated list of credentials for the given agent.
|
||||
// GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials
|
||||
func (c *CredentialClient) ListCredentials(ctx context.Context, agentID string, page, limit int) (*PaginatedCredentials, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials"
|
||||
q := url.Values{}
|
||||
if page > 0 {
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
if len(q) > 0 {
|
||||
rawURL += "?" + q.Encode()
|
||||
}
|
||||
var result PaginatedCredentials
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RotateCredential generates a new secret for the given credential.
|
||||
// POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret
|
||||
func (c *CredentialClient) RotateCredential(ctx context.Context, agentID, credentialID string) (*CredentialWithSecret, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID + "/rotate"
|
||||
var cred CredentialWithSecret
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodPost, rawURL, struct{}{}, token, &cred); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
// RevokeCredential permanently revokes a credential.
|
||||
// DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential
|
||||
func (c *CredentialClient) RevokeCredential(ctx context.Context, agentID, credentialID string) (*Credential, error) {
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID
|
||||
var cred Credential
|
||||
if err := doRequest(ctx, c.httpClient, http.MethodDelete, rawURL, nil, token, &cred); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
146
sdk-go/credentials_test.go
Normal file
146
sdk-go/credentials_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var mockCred = Credential{
|
||||
CredentialID: "cred-1",
|
||||
ClientID: "uuid-1",
|
||||
Status: "active",
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
var mockCredWithSecret = CredentialWithSecret{
|
||||
Credential: mockCred,
|
||||
ClientSecret: "sk_live_abc",
|
||||
}
|
||||
|
||||
var mockPaginatedCreds = PaginatedCredentials{
|
||||
Data: []Credential{mockCred},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
Limit: 20,
|
||||
}
|
||||
|
||||
func TestCredentialClient_GenerateCredential(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(201)
|
||||
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
|
||||
cred, err := client.GenerateCredential(context.Background(), "uuid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cred.ClientSecret != "sk_live_abc" {
|
||||
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
|
||||
}
|
||||
if cred.CredentialID != "cred-1" {
|
||||
t.Errorf("expected cred-1, got %q", cred.CredentialID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialClient_ListCredentials(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockPaginatedCreds)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
|
||||
result, err := client.ListCredentials(context.Background(), "uuid-1", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Errorf("expected total 1, got %d", result.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialClient_RotateCredential(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expectedPath := "/api/v1/agents/uuid-1/credentials/cred-1/rotate"
|
||||
if r.Method != http.MethodPost || r.URL.Path != expectedPath {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
|
||||
cred, err := client.RotateCredential(context.Background(), "uuid-1", "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cred.ClientSecret != "sk_live_abc" {
|
||||
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialClient_RevokeCredential(t *testing.T) {
|
||||
revokedAt := "2026-01-02T00:00:00Z"
|
||||
revoked := Credential{
|
||||
CredentialID: "cred-1",
|
||||
ClientID: "uuid-1",
|
||||
Status: "revoked",
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
RevokedAt: &revokedAt,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(revoked)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
|
||||
cred, err := client.RevokeCredential(context.Background(), "uuid-1", "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cred.Status != "revoked" {
|
||||
t.Errorf("expected revoked, got %q", cred.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialClient_Error_Propagated(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(404)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"code": "AgentNotFoundError",
|
||||
"message": "Not found.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.GenerateCredential(context.Background(), "bad-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*AgentIdPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AgentIdPError, got %T", err)
|
||||
}
|
||||
if apiErr.HTTPStatus != 404 {
|
||||
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
|
||||
}
|
||||
}
|
||||
83
sdk-go/errors.go
Normal file
83
sdk-go/errors.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AgentIdPError is returned for all API and network failures.
|
||||
// It implements the error interface.
|
||||
type AgentIdPError struct {
|
||||
// Code is a machine-readable error code (e.g. "AgentNotFoundError").
|
||||
Code string
|
||||
// Message is a human-readable description.
|
||||
Message string
|
||||
// HTTPStatus is the HTTP response status code, or 0 for network errors.
|
||||
HTTPStatus int
|
||||
// Details contains additional structured context, if provided by the API.
|
||||
Details map[string]interface{}
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *AgentIdPError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// apiErrorBody is the standard JSON error body from the AgentIdP REST API.
|
||||
type apiErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// oauth2ErrorBody is the standard JSON error body from OAuth 2.0 token endpoints.
|
||||
type oauth2ErrorBody struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// parseAPIError attempts to unmarshal a JSON response body into an AgentIdPError.
|
||||
// Falls back to a generic UNKNOWN_ERROR if the body cannot be parsed.
|
||||
func parseAPIError(body []byte, status int) *AgentIdPError {
|
||||
var apiErr apiErrorBody
|
||||
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Code != "" {
|
||||
return &AgentIdPError{
|
||||
Code: apiErr.Code,
|
||||
Message: apiErr.Message,
|
||||
HTTPStatus: status,
|
||||
Details: apiErr.Details,
|
||||
}
|
||||
}
|
||||
return &AgentIdPError{
|
||||
Code: "UNKNOWN_ERROR",
|
||||
Message: fmt.Sprintf("unexpected HTTP %d", status),
|
||||
HTTPStatus: status,
|
||||
}
|
||||
}
|
||||
|
||||
// parseOAuth2Error attempts to unmarshal a JSON response body into an AgentIdPError
|
||||
// using the OAuth 2.0 error format. Falls back to UNKNOWN_ERROR on parse failure.
|
||||
func parseOAuth2Error(body []byte, status int) *AgentIdPError {
|
||||
var oauthErr oauth2ErrorBody
|
||||
if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.Error != "" {
|
||||
return &AgentIdPError{
|
||||
Code: oauthErr.Error,
|
||||
Message: oauthErr.ErrorDescription,
|
||||
HTTPStatus: status,
|
||||
}
|
||||
}
|
||||
return &AgentIdPError{
|
||||
Code: "UNKNOWN_ERROR",
|
||||
Message: fmt.Sprintf("unexpected HTTP %d", status),
|
||||
HTTPStatus: status,
|
||||
}
|
||||
}
|
||||
|
||||
// newNetworkError creates an AgentIdPError for transport-level failures.
|
||||
func newNetworkError(cause error) *AgentIdPError {
|
||||
return &AgentIdPError{
|
||||
Code: "NETWORK_ERROR",
|
||||
Message: fmt.Sprintf("network error: %s", cause.Error()),
|
||||
HTTPStatus: 0,
|
||||
}
|
||||
}
|
||||
85
sdk-go/errors_test.go
Normal file
85
sdk-go/errors_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentIdPError_Error(t *testing.T) {
|
||||
err := &AgentIdPError{Code: "AgentNotFoundError", Message: "Agent not found.", HTTPStatus: 404}
|
||||
if err.Error() != "Agent not found." {
|
||||
t.Errorf("expected 'Agent not found.', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAPIError_ValidBody(t *testing.T) {
|
||||
body := []byte(`{"code":"AgentNotFoundError","message":"Not found.","details":{"id":"x"}}`)
|
||||
err := parseAPIError(body, 404)
|
||||
if err.Code != "AgentNotFoundError" {
|
||||
t.Errorf("expected code AgentNotFoundError, got %q", err.Code)
|
||||
}
|
||||
if err.HTTPStatus != 404 {
|
||||
t.Errorf("expected status 404, got %d", err.HTTPStatus)
|
||||
}
|
||||
if err.Details == nil {
|
||||
t.Error("expected non-nil Details")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAPIError_UnparseableBody(t *testing.T) {
|
||||
err := parseAPIError([]byte("not json"), 500)
|
||||
if err.Code != "UNKNOWN_ERROR" {
|
||||
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
|
||||
}
|
||||
if err.HTTPStatus != 500 {
|
||||
t.Errorf("expected 500, got %d", err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAPIError_EmptyCode(t *testing.T) {
|
||||
// Valid JSON but no "code" field → falls back to UNKNOWN_ERROR
|
||||
err := parseAPIError([]byte(`{"message":"oops"}`), 503)
|
||||
if err.Code != "UNKNOWN_ERROR" {
|
||||
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOAuth2Error_ValidBody(t *testing.T) {
|
||||
body := []byte(`{"error":"invalid_client","error_description":"Bad credentials."}`)
|
||||
err := parseOAuth2Error(body, 401)
|
||||
if err.Code != "invalid_client" {
|
||||
t.Errorf("expected invalid_client, got %q", err.Code)
|
||||
}
|
||||
if err.Message != "Bad credentials." {
|
||||
t.Errorf("expected 'Bad credentials.', got %q", err.Message)
|
||||
}
|
||||
if err.HTTPStatus != 401 {
|
||||
t.Errorf("expected 401, got %d", err.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOAuth2Error_UnparseableBody(t *testing.T) {
|
||||
err := parseOAuth2Error([]byte("garbage"), 400)
|
||||
if err.Code != "UNKNOWN_ERROR" {
|
||||
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNetworkError(t *testing.T) {
|
||||
cause := &testError{msg: "connection refused"}
|
||||
err := newNetworkError(cause)
|
||||
if err.Code != "NETWORK_ERROR" {
|
||||
t.Errorf("expected NETWORK_ERROR, got %q", err.Code)
|
||||
}
|
||||
if err.HTTPStatus != 0 {
|
||||
t.Errorf("expected HTTPStatus 0, got %d", err.HTTPStatus)
|
||||
}
|
||||
if !strings.Contains(err.Message, "connection refused") {
|
||||
t.Errorf("expected message to contain 'connection refused', got %q", err.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// testError is a simple error implementation for testing.
|
||||
type testError struct{ msg string }
|
||||
|
||||
func (e *testError) Error() string { return e.msg }
|
||||
3
sdk-go/go.mod
Normal file
3
sdk-go/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/sentryagent/idp-sdk-go
|
||||
|
||||
go 1.21
|
||||
79
sdk-go/request.go
Normal file
79
sdk-go/request.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// doRequest performs an authenticated JSON HTTP request.
|
||||
//
|
||||
// - method: HTTP method (GET, POST, PATCH, DELETE)
|
||||
// - url: full URL (base + path + query)
|
||||
// - body: request body (marshalled to JSON), or nil for bodyless requests
|
||||
// - token: Bearer token for Authorization header
|
||||
// - out: pointer to unmarshal the response body into, or nil to discard
|
||||
//
|
||||
// Returns nil on 2xx; returns *AgentIdPError on HTTP errors or network failures.
|
||||
// 204 No Content responses are considered success; out is not populated.
|
||||
func doRequest(ctx context.Context, client *http.Client, method, url string, body interface{}, token string, out interface{}) error {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return &AgentIdPError{
|
||||
Code: "SERIALIZATION_ERROR",
|
||||
Message: fmt.Sprintf("failed to marshal request body: %s", err.Error()),
|
||||
HTTPStatus: 0,
|
||||
}
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return &AgentIdPError{
|
||||
Code: "REQUEST_BUILD_ERROR",
|
||||
Message: fmt.Sprintf("failed to build request: %s", err.Error()),
|
||||
HTTPStatus: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return newNetworkError(err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return newNetworkError(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return parseAPIError(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
if out != nil && resp.StatusCode != http.StatusNoContent {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return &AgentIdPError{
|
||||
Code: "PARSE_ERROR",
|
||||
Message: fmt.Sprintf("failed to parse response: %s", err.Error()),
|
||||
HTTPStatus: resp.StatusCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
129
sdk-go/token_manager.go
Normal file
129
sdk-go/token_manager.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const refreshBufferSeconds = 60
|
||||
|
||||
// cachedToken holds an access token and its expiry time.
|
||||
type cachedToken struct {
|
||||
accessToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// isValid returns true if the token will not expire within the refresh buffer.
|
||||
func (c *cachedToken) isValid() bool {
|
||||
return time.Now().Add(refreshBufferSeconds * time.Second).Before(c.expiresAt)
|
||||
}
|
||||
|
||||
// TokenManager obtains and caches OAuth 2.0 client credentials tokens.
|
||||
// It is safe for concurrent use by multiple goroutines.
|
||||
type TokenManager struct {
|
||||
baseURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
scope string
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
cached *cachedToken
|
||||
}
|
||||
|
||||
// NewTokenManager creates a TokenManager that fetches tokens from baseURL
|
||||
// using the given client credentials and scope.
|
||||
func NewTokenManager(baseURL, clientID, clientSecret, scope string) *TokenManager {
|
||||
return &TokenManager{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: scope,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns a valid access token, fetching a new one if the cache is
|
||||
// empty or the cached token is within the refresh buffer window.
|
||||
// It is goroutine-safe.
|
||||
func (tm *TokenManager) GetToken(ctx context.Context) (string, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
if tm.cached != nil && tm.cached.isValid() {
|
||||
return tm.cached.accessToken, nil
|
||||
}
|
||||
|
||||
token, err := tm.fetchToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tm.cached = token
|
||||
return token.accessToken, nil
|
||||
}
|
||||
|
||||
// ClearCache invalidates the cached token. The next call to GetToken will
|
||||
// fetch a fresh token from the server.
|
||||
func (tm *TokenManager) ClearCache() {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
tm.cached = nil
|
||||
}
|
||||
|
||||
// fetchToken performs the OAuth 2.0 client credentials grant.
|
||||
// Must be called with mu held.
|
||||
func (tm *TokenManager) fetchToken(ctx context.Context) (*cachedToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", tm.clientID)
|
||||
form.Set("client_secret", tm.clientSecret)
|
||||
form.Set("scope", tm.scope)
|
||||
|
||||
tokenURL := tm.baseURL + "/api/v1/token"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewBufferString(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, &AgentIdPError{
|
||||
Code: "REQUEST_BUILD_ERROR",
|
||||
Message: fmt.Sprintf("failed to build token request: %s", err.Error()),
|
||||
HTTPStatus: 0,
|
||||
}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := tm.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, newNetworkError(err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, newNetworkError(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseOAuth2Error(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
var tr TokenResponse
|
||||
if err := json.Unmarshal(respBody, &tr); err != nil {
|
||||
return nil, &AgentIdPError{
|
||||
Code: "PARSE_ERROR",
|
||||
Message: fmt.Sprintf("failed to parse token response: %s", err.Error()),
|
||||
HTTPStatus: resp.StatusCode,
|
||||
}
|
||||
}
|
||||
|
||||
return &cachedToken{
|
||||
accessToken: tr.AccessToken,
|
||||
expiresAt: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
169
sdk-go/token_manager_test.go
Normal file
169
sdk-go/token_manager_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTokenServer(t *testing.T, statusCode int, body interface{}) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
}
|
||||
|
||||
var tokenResp = map[string]interface{}{
|
||||
"access_token": "eyJ.abc.def",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read",
|
||||
}
|
||||
|
||||
func TestTokenManager_GetToken_Issues(t *testing.T) {
|
||||
srv := newTokenServer(t, 200, tokenResp)
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
|
||||
tok, err := tm.GetToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "eyJ.abc.def" {
|
||||
t.Errorf("expected token eyJ.abc.def, got %q", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManager_GetToken_Caches(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tokenResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
|
||||
_, _ = tm.GetToken(context.Background())
|
||||
_, _ = tm.GetToken(context.Background())
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected 1 HTTP call (cached), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManager_GetToken_RefreshesNearExpiry(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "eyJ.abc.def",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
|
||||
_, _ = tm.GetToken(context.Background())
|
||||
|
||||
// Force the cached token to appear nearly expired
|
||||
tm.mu.Lock()
|
||||
tm.cached = &cachedToken{
|
||||
accessToken: "old-token",
|
||||
expiresAt: time.Now().Add(30 * time.Second), // < refreshBufferSeconds
|
||||
}
|
||||
tm.mu.Unlock()
|
||||
|
||||
tok, err := tm.GetToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "eyJ.abc.def" {
|
||||
t.Errorf("expected refreshed token, got %q", tok)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected 2 HTTP calls (initial + refresh), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManager_GetToken_AuthFailure(t *testing.T) {
|
||||
srv := newTokenServer(t, 401, map[string]interface{}{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Bad credentials.",
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "bad-secret", "agents:read")
|
||||
_, err := tm.GetToken(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*AgentIdPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AgentIdPError, got %T", err)
|
||||
}
|
||||
if apiErr.Code != "invalid_client" {
|
||||
t.Errorf("expected code invalid_client, got %q", apiErr.Code)
|
||||
}
|
||||
if apiErr.HTTPStatus != 401 {
|
||||
t.Errorf("expected HTTPStatus 401, got %d", apiErr.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManager_ClearCache(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tokenResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
|
||||
_, _ = tm.GetToken(context.Background())
|
||||
tm.ClearCache()
|
||||
_, _ = tm.GetToken(context.Background())
|
||||
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected 2 HTTP calls (cache cleared), got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenManager_GoroutineSafe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tokenResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tok, err := tm.GetToken(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("goroutine error: %v", err)
|
||||
}
|
||||
if tok != "eyJ.abc.def" {
|
||||
t.Errorf("unexpected token: %q", tok)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
103
sdk-go/token_service.go
Normal file
103
sdk-go/token_service.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TokenServiceClient provides token introspection and revocation.
|
||||
// Token acquisition is handled separately by TokenManager.
|
||||
type TokenServiceClient struct {
|
||||
baseURL string
|
||||
getToken func(ctx context.Context) (string, error)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newTokenServiceClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *TokenServiceClient {
|
||||
return &TokenServiceClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
getToken: getToken,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// IntrospectToken introspects an access token per RFC 7662.
|
||||
// POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse
|
||||
func (c *TokenServiceClient) IntrospectToken(ctx context.Context, accessToken string) (*IntrospectResponse, error) {
|
||||
bearerToken, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("token", accessToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/introspect", bytes.NewBufferString(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build introspect request: " + err.Error()}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, newNetworkError(err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, newNetworkError(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, parseAPIError(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
var result IntrospectResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, &AgentIdPError{Code: "PARSE_ERROR", Message: "failed to parse introspect response: " + err.Error(), HTTPStatus: resp.StatusCode}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RevokeToken revokes an access token.
|
||||
// POST /api/v1/token/revoke (form-encoded) → 200
|
||||
func (c *TokenServiceClient) RevokeToken(ctx context.Context, accessToken string) error {
|
||||
bearerToken, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("token", accessToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/revoke", bytes.NewBufferString(form.Encode()))
|
||||
if err != nil {
|
||||
return &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build revoke request: " + err.Error()}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return newNetworkError(err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return newNetworkError(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return parseAPIError(respBody, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
sdk-go/token_service_test.go
Normal file
108
sdk-go/token_service_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package agentidp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenServiceClient_IntrospectToken_Active(t *testing.T) {
|
||||
introspectResp := map[string]interface{}{
|
||||
"active": true,
|
||||
"sub": "uuid-1",
|
||||
"exp": 9999999999,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/introspect" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("expected form content-type, got %q", ct)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("parse form: %v", err)
|
||||
}
|
||||
if r.FormValue("token") == "" {
|
||||
t.Error("missing 'token' form field")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(introspectResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
|
||||
result, err := client.IntrospectToken(context.Background(), "some-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Active {
|
||||
t.Error("expected active=true")
|
||||
}
|
||||
if result.Sub == nil || *result.Sub != "uuid-1" {
|
||||
t.Errorf("expected sub=uuid-1, got %v", result.Sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenServiceClient_IntrospectToken_Inactive(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"active": false})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
|
||||
result, err := client.IntrospectToken(context.Background(), "expired-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Active {
|
||||
t.Error("expected active=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenServiceClient_RevokeToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/revoke" {
|
||||
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("expected form content-type, got %q", ct)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
|
||||
err := client.RevokeToken(context.Background(), "some-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenServiceClient_IntrospectToken_Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"code": "UnauthorizedError",
|
||||
"message": "Invalid token.",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
|
||||
_, err := client.IntrospectToken(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(*AgentIdPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AgentIdPError, got %T", err)
|
||||
}
|
||||
if apiErr.HTTPStatus != 401 {
|
||||
t.Errorf("expected 401, got %d", apiErr.HTTPStatus)
|
||||
}
|
||||
}
|
||||
131
sdk-go/types.go
Normal file
131
sdk-go/types.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package agentidp provides a Go client for the SentryAgent.ai AgentIdP API.
|
||||
// It covers all 14 endpoints across agent registry, credential management,
|
||||
// OAuth 2.0 token operations, and audit log queries.
|
||||
package agentidp
|
||||
|
||||
// Agent is a registered AI agent identity.
|
||||
type Agent struct {
|
||||
AgentID string `json:"agentId"`
|
||||
Email string `json:"email"`
|
||||
AgentType string `json:"agentType"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Owner string `json:"owner"`
|
||||
DeploymentEnv string `json:"deploymentEnv"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RegisterAgentRequest is the body for POST /api/v1/agents.
|
||||
type RegisterAgentRequest struct {
|
||||
Email string `json:"email"`
|
||||
AgentType string `json:"agentType"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Owner string `json:"owner"`
|
||||
DeploymentEnv string `json:"deploymentEnv"`
|
||||
}
|
||||
|
||||
// UpdateAgentRequest is the body for PATCH /api/v1/agents/:id.
|
||||
// All fields are optional — only non-nil pointer fields are sent.
|
||||
type UpdateAgentRequest struct {
|
||||
AgentType *string `json:"agentType,omitempty"`
|
||||
Version *string `json:"version,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
DeploymentEnv *string `json:"deploymentEnv,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// PaginatedAgents is a paginated list of agents.
|
||||
type PaginatedAgents struct {
|
||||
Data []Agent `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ListAgentsParams contains optional query parameters for ListAgents.
|
||||
type ListAgentsParams struct {
|
||||
Status string
|
||||
AgentType string
|
||||
DeploymentEnv string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Credential is a credential record (ClientSecret is never included).
|
||||
type Credential struct {
|
||||
CredentialID string `json:"credentialId"`
|
||||
ClientID string `json:"clientId"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
RevokedAt *string `json:"revokedAt"`
|
||||
}
|
||||
|
||||
// CredentialWithSecret is a Credential with a one-time plaintext secret.
|
||||
// Returned only on credential creation and rotation.
|
||||
type CredentialWithSecret struct {
|
||||
Credential
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
}
|
||||
|
||||
// PaginatedCredentials is a paginated list of credentials.
|
||||
type PaginatedCredentials struct {
|
||||
Data []Credential `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// TokenResponse is the OAuth 2.0 access token response (RFC 6749).
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// IntrospectResponse is the token introspection response (RFC 7662).
|
||||
type IntrospectResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Sub *string `json:"sub,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Scope *string `json:"scope,omitempty"`
|
||||
TokenType *string `json:"token_type,omitempty"`
|
||||
Iat *int64 `json:"iat,omitempty"`
|
||||
Exp *int64 `json:"exp,omitempty"`
|
||||
}
|
||||
|
||||
// AuditEvent is an immutable audit event record.
|
||||
type AuditEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
AgentID string `json:"agentId"`
|
||||
Action string `json:"action"`
|
||||
Outcome string `json:"outcome"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// PaginatedAuditEvents is a paginated list of audit events.
|
||||
type PaginatedAuditEvents struct {
|
||||
Data []AuditEvent `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// QueryAuditParams contains optional query parameters for QueryAuditLog.
|
||||
type QueryAuditParams struct {
|
||||
AgentID string
|
||||
Action string
|
||||
Outcome string
|
||||
FromDate string
|
||||
ToDate string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
1
sdk-java/.gitignore
vendored
Normal file
1
sdk-java/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
190
sdk-java/README.md
Normal file
190
sdk-java/README.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# SentryAgent.ai AgentIdP — Java SDK
|
||||
|
||||
Official Java client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 17+
|
||||
- A running AgentIdP server
|
||||
|
||||
## Installation
|
||||
|
||||
### Maven
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>ai.sentryagent</groupId>
|
||||
<artifactId>idp-sdk</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```java
|
||||
import ai.sentryagent.idp.AgentIdPClient;
|
||||
import ai.sentryagent.idp.models.*;
|
||||
|
||||
AgentIdPClient client = new AgentIdPClient(
|
||||
"https://idp.example.com",
|
||||
"your-agent-client-id",
|
||||
"sk_live_..."
|
||||
);
|
||||
|
||||
// Register a new AI agent
|
||||
Agent agent = client.agents().registerAgent(
|
||||
RegisterAgentRequest.builder()
|
||||
.email("screener@example.com")
|
||||
.agentType("screener")
|
||||
.version("1.0.0")
|
||||
.capabilities(List.of("read", "classify"))
|
||||
.owner("platform-team")
|
||||
.deploymentEnv("production")
|
||||
.build()
|
||||
);
|
||||
System.out.println("Registered: " + agent.getAgentId());
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
OAuth 2.0 Client Credentials are managed automatically. Tokens are cached and refreshed 60 seconds before expiry. The `TokenManager` is thread-safe.
|
||||
|
||||
```java
|
||||
// Custom scope (optional — defaults to all four scopes)
|
||||
AgentIdPClient client = new AgentIdPClient(
|
||||
"https://idp.example.com",
|
||||
"my-client-id",
|
||||
"my-client-secret",
|
||||
"agents:read agents:write"
|
||||
);
|
||||
```
|
||||
|
||||
## Agent Registry
|
||||
|
||||
```java
|
||||
// Register
|
||||
Agent agent = client.agents().registerAgent(
|
||||
RegisterAgentRequest.builder()
|
||||
.email("...").agentType("screener").version("1.0.0")
|
||||
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
|
||||
.build());
|
||||
|
||||
// List (with optional filters)
|
||||
PaginatedAgents agents = client.agents().listAgents(
|
||||
ListAgentsParams.builder().status("active").page(1).limit(20).build());
|
||||
|
||||
// Get by ID
|
||||
Agent agent = client.agents().getAgent("agent-uuid");
|
||||
|
||||
// Partial update
|
||||
Agent updated = client.agents().updateAgent("agent-uuid",
|
||||
UpdateAgentRequest.builder().version("2.0.0").build());
|
||||
|
||||
// Decommission (permanent)
|
||||
client.agents().decommissionAgent("agent-uuid");
|
||||
```
|
||||
|
||||
## Credential Management
|
||||
|
||||
```java
|
||||
// Generate (returns one-time ClientSecret)
|
||||
CredentialWithSecret cred = client.credentials().generateCredential("agent-uuid");
|
||||
System.out.println(cred.getClientSecret()); // store this — shown only once
|
||||
|
||||
// List
|
||||
PaginatedCredentials creds = client.credentials().listCredentials("agent-uuid", 1, 20);
|
||||
|
||||
// Rotate
|
||||
CredentialWithSecret newCred = client.credentials().rotateCredential("agent-uuid", "cred-uuid");
|
||||
|
||||
// Revoke
|
||||
Credential revoked = client.credentials().revokeCredential("agent-uuid", "cred-uuid");
|
||||
```
|
||||
|
||||
## Token Operations
|
||||
|
||||
```java
|
||||
// Introspect (RFC 7662)
|
||||
IntrospectResponse result = client.tokens().introspectToken("access-token-to-check");
|
||||
if (result.isActive()) {
|
||||
System.out.println("Token belongs to: " + result.getSub());
|
||||
}
|
||||
|
||||
// Revoke
|
||||
client.tokens().revokeToken("access-token-to-revoke");
|
||||
```
|
||||
|
||||
## Audit Log
|
||||
|
||||
```java
|
||||
// Query with filters
|
||||
PaginatedAuditEvents events = client.audit().queryAuditLog(
|
||||
QueryAuditParams.builder()
|
||||
.agentId("agent-uuid")
|
||||
.action("token.issued")
|
||||
.outcome("success")
|
||||
.fromDate("2026-01-01")
|
||||
.toDate("2026-01-31")
|
||||
.page(1).limit(50)
|
||||
.build());
|
||||
|
||||
// Get single event
|
||||
AuditEvent event = client.audit().getAuditEvent("event-uuid");
|
||||
```
|
||||
|
||||
## Async Methods
|
||||
|
||||
Every sync method has an async counterpart returning `CompletableFuture<T>`:
|
||||
|
||||
```java
|
||||
CompletableFuture<Agent> future = client.agents().getAgentAsync("uuid-1");
|
||||
future.thenAccept(agent -> System.out.println(agent.getAgentId()));
|
||||
|
||||
// Compose multiple async calls
|
||||
client.agents().getAgentAsync("uuid-1")
|
||||
.thenCompose(agent -> client.credentials().generateCredentialAsync(agent.getAgentId()))
|
||||
.thenAccept(cred -> System.out.println("New secret: " + cred.getClientSecret()));
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are thrown as `AgentIdPException` (extends `RuntimeException`):
|
||||
|
||||
```java
|
||||
try {
|
||||
Agent agent = client.agents().getAgent("unknown-id");
|
||||
} catch (AgentIdPException ex) {
|
||||
System.out.printf("code=%s status=%d%n", ex.getCode(), ex.getHttpStatus());
|
||||
// e.g. code=AgentNotFoundError status=404
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Type | Description |
|
||||
|------------------|--------------------------|-------------------------------------------------|
|
||||
| `getCode()` | `String` | Machine-readable error code |
|
||||
| `getMessage()` | `String` | Human-readable description |
|
||||
| `getHttpStatus()`| `int` | HTTP status code (0 for network/build errors) |
|
||||
| `getDetails()` | `Map<String, Object>` | Optional structured context from the API |
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Endpoint | Method | SDK Method |
|
||||
|--------------------------------------------------|--------|-----------------------------------------|
|
||||
| POST /api/v1/agents | POST | `agents().registerAgent()` |
|
||||
| GET /api/v1/agents | GET | `agents().listAgents()` |
|
||||
| GET /api/v1/agents/:id | GET | `agents().getAgent()` |
|
||||
| PATCH /api/v1/agents/:id | PATCH | `agents().updateAgent()` |
|
||||
| DELETE /api/v1/agents/:id | DELETE | `agents().decommissionAgent()` |
|
||||
| POST /api/v1/agents/:id/credentials | POST | `credentials().generateCredential()` |
|
||||
| GET /api/v1/agents/:id/credentials | GET | `credentials().listCredentials()` |
|
||||
| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `credentials().rotateCredential()` |
|
||||
| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `credentials().revokeCredential()` |
|
||||
| POST /api/v1/token | POST | (TokenManager — automatic) |
|
||||
| POST /api/v1/token/introspect | POST | `tokens().introspectToken()` |
|
||||
| POST /api/v1/token/revoke | POST | `tokens().revokeToken()` |
|
||||
| GET /api/v1/audit | GET | `audit().queryAuditLog()` |
|
||||
| GET /api/v1/audit/:id | GET | `audit().getAuditEvent()` |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see [LICENSE](../LICENSE).
|
||||
100
sdk-java/pom.xml
Normal file
100
sdk-java/pom.xml
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>ai.sentryagent</groupId>
|
||||
<artifactId>idp-sdk</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>SentryAgent.ai AgentIdP Java SDK</name>
|
||||
<description>Java client for the SentryAgent.ai AgentIdP API</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<jackson.version>2.17.0</jackson.version>
|
||||
<junit.version>5.10.2</junit.version>
|
||||
<jacoco.version>0.8.11</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- JSON serialization/deserialization -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JUnit 5 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<configuration>
|
||||
<useModulePath>false</useModulePath>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- JaCoCo coverage gate: >80% instruction coverage required -->
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals><goal>prepare-agent</goal></goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals><goal>report</goal></goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<phase>verify</phase>
|
||||
<goals><goal>check</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,88 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import ai.sentryagent.idp.internal.HttpHelper;
|
||||
import ai.sentryagent.idp.services.*;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Top-level client for the SentryAgent.ai AgentIdP API.
|
||||
* Composes all four service clients and manages token acquisition automatically.
|
||||
*
|
||||
* <pre>{@code
|
||||
* AgentIdPClient client = new AgentIdPClient(
|
||||
* "https://idp.example.com",
|
||||
* "my-client-id",
|
||||
* "sk_live_...",
|
||||
* "agents:read agents:write tokens:read audit:read"
|
||||
* );
|
||||
*
|
||||
* Agent agent = client.agents().getAgent("uuid-1");
|
||||
* }</pre>
|
||||
*/
|
||||
public final class AgentIdPClient {
|
||||
|
||||
private static final String DEFAULT_SCOPE = "agents:read agents:write tokens:read audit:read";
|
||||
|
||||
private final TokenManager tokenManager;
|
||||
private final AgentRegistryClient agentsClient;
|
||||
private final CredentialClient credentialsClient;
|
||||
private final TokenClient tokensClient;
|
||||
private final AuditClient auditClient;
|
||||
|
||||
/**
|
||||
* Creates a new AgentIdPClient with default scope and a shared HttpClient.
|
||||
*
|
||||
* @param baseUrl Root URL of the AgentIdP server (e.g. {@code "https://idp.example.com"})
|
||||
* @param clientId OAuth 2.0 client ID
|
||||
* @param clientSecret OAuth 2.0 client secret
|
||||
*/
|
||||
public AgentIdPClient(String baseUrl, String clientId, String clientSecret) {
|
||||
this(baseUrl, clientId, clientSecret, DEFAULT_SCOPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AgentIdPClient with a custom scope.
|
||||
*
|
||||
* @param baseUrl Root URL of the AgentIdP server
|
||||
* @param clientId OAuth 2.0 client ID
|
||||
* @param clientSecret OAuth 2.0 client secret
|
||||
* @param scope Space-separated OAuth 2.0 scopes to request
|
||||
*/
|
||||
public AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope) {
|
||||
this(baseUrl, clientId, clientSecret, scope,
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-visible constructor that accepts a custom HttpClient (for testing).
|
||||
*/
|
||||
AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) {
|
||||
String base = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.tokenManager = new TokenManager(base, clientId, clientSecret, scope, httpClient);
|
||||
|
||||
HttpHelper httpHelper = new HttpHelper(httpClient);
|
||||
this.agentsClient = new AgentRegistryClient(base, tokenManager::getToken, httpHelper);
|
||||
this.credentialsClient = new CredentialClient(base, tokenManager::getToken, httpHelper);
|
||||
this.tokensClient = new TokenClient(base, tokenManager::getToken, httpClient);
|
||||
this.auditClient = new AuditClient(base, tokenManager::getToken, httpHelper);
|
||||
}
|
||||
|
||||
/** Returns the Agent Registry service client. */
|
||||
public AgentRegistryClient agents() { return agentsClient; }
|
||||
|
||||
/** Returns the Credential Management service client. */
|
||||
public CredentialClient credentials() { return credentialsClient; }
|
||||
|
||||
/** Returns the Token service client (introspect + revoke). */
|
||||
public TokenClient tokens() { return tokensClient; }
|
||||
|
||||
/** Returns the Audit Log service client. */
|
||||
public AuditClient audit() { return auditClient; }
|
||||
|
||||
/** Invalidates the cached access token. The next API call will fetch a fresh one. */
|
||||
public void clearTokenCache() { tokenManager.clearCache(); }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thrown for all API and network-level failures.
|
||||
* Extends RuntimeException — callers may catch if needed but are not required to.
|
||||
*/
|
||||
public final class AgentIdPException extends RuntimeException {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final String code;
|
||||
private final int httpStatus;
|
||||
private final Map<String, Object> details;
|
||||
|
||||
public AgentIdPException(String code, String message, int httpStatus, Map<String, Object> details, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.code = code;
|
||||
this.httpStatus = httpStatus;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public AgentIdPException(String code, String message, int httpStatus) {
|
||||
this(code, message, httpStatus, null, null);
|
||||
}
|
||||
|
||||
/** Machine-readable error code (e.g. {@code "AgentNotFoundError"}). */
|
||||
public String getCode() { return code; }
|
||||
|
||||
/** HTTP response status code, or 0 for network/build errors. */
|
||||
public int getHttpStatus() { return httpStatus; }
|
||||
|
||||
/** Optional structured context from the API response. */
|
||||
public Map<String, Object> getDetails() { return details; }
|
||||
|
||||
// ─── Factory methods ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates an AgentIdPException from a raw JSON API error response body.
|
||||
* Falls back to UNKNOWN_ERROR if the body cannot be parsed.
|
||||
*/
|
||||
public static AgentIdPException fromApiError(String responseBody, int httpStatus) {
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(responseBody);
|
||||
String code = node.path("code").asText("UNKNOWN_ERROR");
|
||||
String message = node.path("message").asText("Unexpected HTTP " + httpStatus);
|
||||
if (code.isEmpty()) code = "UNKNOWN_ERROR";
|
||||
return new AgentIdPException(code, message, httpStatus);
|
||||
} catch (Exception e) {
|
||||
return new AgentIdPException("UNKNOWN_ERROR", "Unexpected HTTP " + httpStatus, httpStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AgentIdPException from an OAuth 2.0 error response body.
|
||||
* Falls back to unknown_error if the body cannot be parsed.
|
||||
*/
|
||||
public static AgentIdPException fromOAuth2Error(String responseBody, int httpStatus) {
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(responseBody);
|
||||
String code = node.path("error").asText("unknown_error");
|
||||
String message = node.path("error_description").asText("Unexpected HTTP " + httpStatus);
|
||||
if (code.isEmpty()) code = "unknown_error";
|
||||
return new AgentIdPException(code, message, httpStatus);
|
||||
} catch (Exception e) {
|
||||
return new AgentIdPException("unknown_error", "Unexpected HTTP " + httpStatus, httpStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an AgentIdPException wrapping a transport-level failure. */
|
||||
public static AgentIdPException networkError(Throwable cause) {
|
||||
return new AgentIdPException("NETWORK_ERROR", "Network error: " + cause.getMessage(), 0, null, cause);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AgentIdPException{code='" + code + "', httpStatus=" + httpStatus + ", message='" + getMessage() + "'}";
|
||||
}
|
||||
}
|
||||
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal file
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal file
@@ -0,0 +1,101 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import ai.sentryagent.idp.models.TokenResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Obtains and caches OAuth 2.0 client credentials tokens.
|
||||
* Thread-safe: all cache access is synchronized.
|
||||
* Tokens are refreshed 60 seconds before they expire.
|
||||
*/
|
||||
public final class TokenManager {
|
||||
|
||||
private static final int REFRESH_BUFFER_SECONDS = 60;
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final String baseUrl;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final String scope;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private String cachedToken;
|
||||
private Instant tokenExpiresAt;
|
||||
|
||||
public TokenManager(String baseUrl, String clientId, String clientSecret, String scope) {
|
||||
this(baseUrl, clientId, clientSecret, scope,
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build());
|
||||
}
|
||||
|
||||
/** Package-visible constructor for injecting a custom HttpClient in tests. */
|
||||
TokenManager(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.scope = scope;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid access token, fetching a new one if the cache is empty
|
||||
* or within the 60-second refresh buffer.
|
||||
*/
|
||||
public synchronized String getToken() {
|
||||
if (cachedToken != null && tokenExpiresAt != null
|
||||
&& Instant.now().plusSeconds(REFRESH_BUFFER_SECONDS).isBefore(tokenExpiresAt)) {
|
||||
return cachedToken;
|
||||
}
|
||||
TokenResponse tr = fetchToken();
|
||||
cachedToken = tr.getAccessToken();
|
||||
tokenExpiresAt = Instant.now().plusSeconds(tr.getExpiresIn());
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
/** Invalidates the cached token. The next call to {@link #getToken()} fetches a fresh one. */
|
||||
public synchronized void clearCache() {
|
||||
cachedToken = null;
|
||||
tokenExpiresAt = null;
|
||||
}
|
||||
|
||||
private TokenResponse fetchToken() {
|
||||
String form = "grant_type=client_credentials"
|
||||
+ "&client_id=" + encode(clientId)
|
||||
+ "&client_secret=" + encode(clientSecret)
|
||||
+ "&scope=" + encode(scope);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/api/v1/token"))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200) {
|
||||
throw AgentIdPException.fromOAuth2Error(resp.body(), resp.statusCode());
|
||||
}
|
||||
return MAPPER.readValue(resp.body(), TokenResponse.class);
|
||||
} catch (AgentIdPException e) {
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw AgentIdPException.networkError(e);
|
||||
} catch (IOException e) {
|
||||
throw AgentIdPException.networkError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package ai.sentryagent.idp.internal;
|
||||
|
||||
import ai.sentryagent.idp.AgentIdPException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Shared HTTP helper for all service clients.
|
||||
* Handles JSON serialization, Authorization header injection, and error mapping.
|
||||
*/
|
||||
public final class HttpHelper {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public HttpHelper(HttpClient httpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a synchronous JSON request and unmarshals the response into {@code responseType}.
|
||||
* Returns null for 204 No Content responses.
|
||||
*
|
||||
* @throws AgentIdPException on HTTP errors or network failures
|
||||
*/
|
||||
public <T> T request(String method, String url, Object body, String token, Class<T> responseType) {
|
||||
try {
|
||||
HttpRequest req = buildRequest(method, url, body, token);
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
return handleResponse(resp, responseType);
|
||||
} catch (AgentIdPException e) {
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw AgentIdPException.networkError(e);
|
||||
} catch (IOException e) {
|
||||
throw AgentIdPException.networkError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an asynchronous JSON request and returns a CompletableFuture.
|
||||
*
|
||||
* @throws AgentIdPException (wrapped in CompletableFuture) on HTTP errors
|
||||
*/
|
||||
public <T> CompletableFuture<T> requestAsync(String method, String url, Object body, String token, Class<T> responseType) {
|
||||
try {
|
||||
HttpRequest req = buildRequest(method, url, body, token);
|
||||
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(resp -> handleResponse(resp, responseType));
|
||||
} catch (Exception e) {
|
||||
return CompletableFuture.failedFuture(AgentIdPException.networkError(e));
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest buildRequest(String method, String url, Object body, String token) throws IOException {
|
||||
HttpRequest.BodyPublisher publisher = body != null
|
||||
? HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body))
|
||||
: HttpRequest.BodyPublishers.noBody();
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.method(method, publisher)
|
||||
.header("Accept", "application/json");
|
||||
|
||||
if (body != null) {
|
||||
builder.header("Content-Type", "application/json");
|
||||
}
|
||||
if (token != null && !token.isEmpty()) {
|
||||
builder.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private <T> T handleResponse(HttpResponse<String> resp, Class<T> responseType) {
|
||||
int status = resp.statusCode();
|
||||
if (status < 200 || status >= 300) {
|
||||
throw AgentIdPException.fromApiError(resp.body(), status);
|
||||
}
|
||||
if (status == 204 || responseType == Void.class) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(resp.body(), responseType);
|
||||
} catch (IOException e) {
|
||||
throw new AgentIdPException("PARSE_ERROR", "Failed to parse response: " + e.getMessage(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal file
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** A registered AI agent identity. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class Agent {
|
||||
|
||||
@JsonProperty("agentId") private String agentId;
|
||||
@JsonProperty("email") private String email;
|
||||
@JsonProperty("agentType") private String agentType;
|
||||
@JsonProperty("version") private String version;
|
||||
@JsonProperty("capabilities") private java.util.List<String> capabilities;
|
||||
@JsonProperty("owner") private String owner;
|
||||
@JsonProperty("deploymentEnv") private String deploymentEnv;
|
||||
@JsonProperty("status") private String status;
|
||||
@JsonProperty("createdAt") private String createdAt;
|
||||
@JsonProperty("updatedAt") private String updatedAt;
|
||||
|
||||
/** Required by Jackson. */
|
||||
public Agent() {}
|
||||
|
||||
public String getAgentId() { return agentId; }
|
||||
public String getEmail() { return email; }
|
||||
public String getAgentType() { return agentType; }
|
||||
public String getVersion() { return version; }
|
||||
public java.util.List<String> getCapabilities() { return capabilities; }
|
||||
public String getOwner() { return owner; }
|
||||
public String getDeploymentEnv() { return deploymentEnv; }
|
||||
public String getStatus() { return status; }
|
||||
public String getCreatedAt() { return createdAt; }
|
||||
public String getUpdatedAt() { return updatedAt; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Agent{agentId='" + agentId + "', email='" + email + "', status='" + status + "'}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Map;
|
||||
|
||||
/** An immutable audit event record. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class AuditEvent {
|
||||
|
||||
@JsonProperty("eventId") private String eventId;
|
||||
@JsonProperty("agentId") private String agentId;
|
||||
@JsonProperty("action") private String action;
|
||||
@JsonProperty("outcome") private String outcome;
|
||||
@JsonProperty("ipAddress") private String ipAddress;
|
||||
@JsonProperty("userAgent") private String userAgent;
|
||||
@JsonProperty("metadata") private Map<String, Object> metadata;
|
||||
@JsonProperty("timestamp") private String timestamp;
|
||||
|
||||
public AuditEvent() {}
|
||||
|
||||
public String getEventId() { return eventId; }
|
||||
public String getAgentId() { return agentId; }
|
||||
public String getAction() { return action; }
|
||||
public String getOutcome() { return outcome; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public Map<String, Object> getMetadata() { return metadata; }
|
||||
public String getTimestamp() { return timestamp; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AuditEvent{eventId='" + eventId + "', action='" + action + "', outcome='" + outcome + "'}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** A credential record (clientSecret is never included). */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class Credential {
|
||||
|
||||
@JsonProperty("credentialId") protected String credentialId;
|
||||
@JsonProperty("clientId") protected String clientId;
|
||||
@JsonProperty("status") protected String status;
|
||||
@JsonProperty("createdAt") protected String createdAt;
|
||||
@JsonProperty("expiresAt") protected String expiresAt;
|
||||
@JsonProperty("revokedAt") protected String revokedAt;
|
||||
|
||||
public Credential() {}
|
||||
|
||||
public String getCredentialId() { return credentialId; }
|
||||
public String getClientId() { return clientId; }
|
||||
public String getStatus() { return status; }
|
||||
public String getCreatedAt() { return createdAt; }
|
||||
public String getExpiresAt() { return expiresAt; }
|
||||
public String getRevokedAt() { return revokedAt; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Credential{credentialId='" + credentialId + "', status='" + status + "'}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Credential with a one-time plaintext clientSecret.
|
||||
* Returned only on credential creation and rotation.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class CredentialWithSecret extends Credential {
|
||||
|
||||
@JsonProperty("clientSecret") private String clientSecret;
|
||||
|
||||
public CredentialWithSecret() {}
|
||||
|
||||
/** The one-time plaintext secret. Store it securely; it is never shown again. */
|
||||
public String getClientSecret() { return clientSecret; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** Token introspection response (RFC 7662). */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class IntrospectResponse {
|
||||
|
||||
@JsonProperty("active") private boolean active;
|
||||
@JsonProperty("sub") private String sub;
|
||||
@JsonProperty("client_id") private String clientId;
|
||||
@JsonProperty("scope") private String scope;
|
||||
@JsonProperty("token_type") private String tokenType;
|
||||
@JsonProperty("iat") private Long iat;
|
||||
@JsonProperty("exp") private Long exp;
|
||||
|
||||
public IntrospectResponse() {}
|
||||
|
||||
public boolean isActive() { return active; }
|
||||
public String getSub() { return sub; }
|
||||
public String getClientId() { return clientId; }
|
||||
public String getScope() { return scope; }
|
||||
public String getTokenType() { return tokenType; }
|
||||
public Long getIat() { return iat; }
|
||||
public Long getExp() { return exp; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
/** Optional query parameters for listing agents. */
|
||||
public final class ListAgentsParams {
|
||||
private final String status;
|
||||
private final String agentType;
|
||||
private final String deploymentEnv;
|
||||
private final Integer page;
|
||||
private final Integer limit;
|
||||
|
||||
private ListAgentsParams(Builder b) {
|
||||
this.status = b.status;
|
||||
this.agentType = b.agentType;
|
||||
this.deploymentEnv = b.deploymentEnv;
|
||||
this.page = b.page;
|
||||
this.limit = b.limit;
|
||||
}
|
||||
|
||||
public String getStatus() { return status; }
|
||||
public String getAgentType() { return agentType; }
|
||||
public String getDeploymentEnv() { return deploymentEnv; }
|
||||
public Integer getPage() { return page; }
|
||||
public Integer getLimit() { return limit; }
|
||||
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static final class Builder {
|
||||
private String status;
|
||||
private String agentType;
|
||||
private String deploymentEnv;
|
||||
private Integer page;
|
||||
private Integer limit;
|
||||
|
||||
public Builder status(String status) { this.status = status; return this; }
|
||||
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
|
||||
public Builder deploymentEnv(String env) { this.deploymentEnv = env; return this; }
|
||||
public Builder page(int page) { this.page = page; return this; }
|
||||
public Builder limit(int limit) { this.limit = limit; return this; }
|
||||
|
||||
public ListAgentsParams build() { return new ListAgentsParams(this); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/** Paginated list of agents. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class PaginatedAgents {
|
||||
|
||||
@JsonProperty("data") private List<Agent> data;
|
||||
@JsonProperty("total") private int total;
|
||||
@JsonProperty("page") private int page;
|
||||
@JsonProperty("limit") private int limit;
|
||||
|
||||
public PaginatedAgents() {}
|
||||
|
||||
public List<Agent> getData() { return data; }
|
||||
public int getTotal() { return total; }
|
||||
public int getPage() { return page; }
|
||||
public int getLimit() { return limit; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/** Paginated list of audit events. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class PaginatedAuditEvents {
|
||||
|
||||
@JsonProperty("data") private List<AuditEvent> data;
|
||||
@JsonProperty("total") private int total;
|
||||
@JsonProperty("page") private int page;
|
||||
@JsonProperty("limit") private int limit;
|
||||
|
||||
public PaginatedAuditEvents() {}
|
||||
|
||||
public List<AuditEvent> getData() { return data; }
|
||||
public int getTotal() { return total; }
|
||||
public int getPage() { return page; }
|
||||
public int getLimit() { return limit; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/** Paginated list of credentials. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class PaginatedCredentials {
|
||||
|
||||
@JsonProperty("data") private List<Credential> data;
|
||||
@JsonProperty("total") private int total;
|
||||
@JsonProperty("page") private int page;
|
||||
@JsonProperty("limit") private int limit;
|
||||
|
||||
public PaginatedCredentials() {}
|
||||
|
||||
public List<Credential> getData() { return data; }
|
||||
public int getTotal() { return total; }
|
||||
public int getPage() { return page; }
|
||||
public int getLimit() { return limit; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
/** Optional query parameters for querying the audit log. */
|
||||
public final class QueryAuditParams {
|
||||
private final String agentId;
|
||||
private final String action;
|
||||
private final String outcome;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final Integer page;
|
||||
private final Integer limit;
|
||||
|
||||
private QueryAuditParams(Builder b) {
|
||||
this.agentId = b.agentId;
|
||||
this.action = b.action;
|
||||
this.outcome = b.outcome;
|
||||
this.fromDate = b.fromDate;
|
||||
this.toDate = b.toDate;
|
||||
this.page = b.page;
|
||||
this.limit = b.limit;
|
||||
}
|
||||
|
||||
public String getAgentId() { return agentId; }
|
||||
public String getAction() { return action; }
|
||||
public String getOutcome() { return outcome; }
|
||||
public String getFromDate() { return fromDate; }
|
||||
public String getToDate() { return toDate; }
|
||||
public Integer getPage() { return page; }
|
||||
public Integer getLimit() { return limit; }
|
||||
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static final class Builder {
|
||||
private String agentId;
|
||||
private String action;
|
||||
private String outcome;
|
||||
private String fromDate;
|
||||
private String toDate;
|
||||
private Integer page;
|
||||
private Integer limit;
|
||||
|
||||
public Builder agentId(String agentId) { this.agentId = agentId; return this; }
|
||||
public Builder action(String action) { this.action = action; return this; }
|
||||
public Builder outcome(String outcome) { this.outcome = outcome; return this; }
|
||||
public Builder fromDate(String from) { this.fromDate = from; return this; }
|
||||
public Builder toDate(String to) { this.toDate = to; return this; }
|
||||
public Builder page(int page) { this.page = page; return this; }
|
||||
public Builder limit(int limit) { this.limit = limit; return this; }
|
||||
|
||||
public QueryAuditParams build() { return new QueryAuditParams(this); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/** Request body for POST /api/v1/agents. */
|
||||
public final class RegisterAgentRequest {
|
||||
|
||||
@JsonProperty("email") private final String email;
|
||||
@JsonProperty("agentType") private final String agentType;
|
||||
@JsonProperty("version") private final String version;
|
||||
@JsonProperty("capabilities") private final List<String> capabilities;
|
||||
@JsonProperty("owner") private final String owner;
|
||||
@JsonProperty("deploymentEnv") private final String deploymentEnv;
|
||||
|
||||
private RegisterAgentRequest(Builder b) {
|
||||
this.email = b.email;
|
||||
this.agentType = b.agentType;
|
||||
this.version = b.version;
|
||||
this.capabilities = b.capabilities;
|
||||
this.owner = b.owner;
|
||||
this.deploymentEnv = b.deploymentEnv;
|
||||
}
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public String getAgentType() { return agentType; }
|
||||
public String getVersion() { return version; }
|
||||
public List<String> getCapabilities() { return capabilities; }
|
||||
public String getOwner() { return owner; }
|
||||
public String getDeploymentEnv() { return deploymentEnv; }
|
||||
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static final class Builder {
|
||||
private String email;
|
||||
private String agentType;
|
||||
private String version;
|
||||
private List<String> capabilities;
|
||||
private String owner;
|
||||
private String deploymentEnv;
|
||||
|
||||
public Builder email(String email) { this.email = email; return this; }
|
||||
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
|
||||
public Builder version(String version) { this.version = version; return this; }
|
||||
public Builder capabilities(List<String> capabilities) { this.capabilities = capabilities; return this; }
|
||||
public Builder owner(String owner) { this.owner = owner; return this; }
|
||||
public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; }
|
||||
|
||||
public RegisterAgentRequest build() { return new RegisterAgentRequest(this); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** OAuth 2.0 access token response (RFC 6749). */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public final class TokenResponse {
|
||||
|
||||
@JsonProperty("access_token") private String accessToken;
|
||||
@JsonProperty("token_type") private String tokenType;
|
||||
@JsonProperty("expires_in") private int expiresIn;
|
||||
@JsonProperty("scope") private String scope;
|
||||
|
||||
public TokenResponse() {}
|
||||
|
||||
public String getAccessToken() { return accessToken; }
|
||||
public String getTokenType() { return tokenType; }
|
||||
public int getExpiresIn() { return expiresIn; }
|
||||
public String getScope() { return scope; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai.sentryagent.idp.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Request body for PATCH /api/v1/agents/:id.
|
||||
* All fields are optional — null fields are omitted from the JSON body.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public final class UpdateAgentRequest {
|
||||
|
||||
@JsonProperty("agentType") private final String agentType;
|
||||
@JsonProperty("version") private final String version;
|
||||
@JsonProperty("capabilities") private final List<String> capabilities;
|
||||
@JsonProperty("owner") private final String owner;
|
||||
@JsonProperty("deploymentEnv") private final String deploymentEnv;
|
||||
@JsonProperty("status") private final String status;
|
||||
|
||||
private UpdateAgentRequest(Builder b) {
|
||||
this.agentType = b.agentType;
|
||||
this.version = b.version;
|
||||
this.capabilities = b.capabilities;
|
||||
this.owner = b.owner;
|
||||
this.deploymentEnv = b.deploymentEnv;
|
||||
this.status = b.status;
|
||||
}
|
||||
|
||||
public String getAgentType() { return agentType; }
|
||||
public String getVersion() { return version; }
|
||||
public List<String> getCapabilities() { return capabilities; }
|
||||
public String getOwner() { return owner; }
|
||||
public String getDeploymentEnv() { return deploymentEnv; }
|
||||
public String getStatus() { return status; }
|
||||
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static final class Builder {
|
||||
private String agentType;
|
||||
private String version;
|
||||
private List<String> capabilities;
|
||||
private String owner;
|
||||
private String deploymentEnv;
|
||||
private String status;
|
||||
|
||||
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
|
||||
public Builder version(String version) { this.version = version; return this; }
|
||||
public Builder capabilities(List<String> capabilities) { this.capabilities = capabilities; return this; }
|
||||
public Builder owner(String owner) { this.owner = owner; return this; }
|
||||
public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; }
|
||||
public Builder status(String status) { this.status = status; return this; }
|
||||
|
||||
public UpdateAgentRequest build() { return new UpdateAgentRequest(this); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ai.sentryagent.idp.services;
|
||||
|
||||
import ai.sentryagent.idp.internal.HttpHelper;
|
||||
import ai.sentryagent.idp.models.*;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Client for the Agent Registry API endpoints.
|
||||
* Provides both synchronous and asynchronous (CompletableFuture) methods.
|
||||
*/
|
||||
public final class AgentRegistryClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final Supplier<String> tokenSupplier;
|
||||
private final HttpHelper http;
|
||||
|
||||
public AgentRegistryClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/v1/agents → 201 Agent */
|
||||
public Agent registerAgent(RegisterAgentRequest request) {
|
||||
return http.request("POST", baseUrl + "/api/v1/agents", request, tokenSupplier.get(), Agent.class);
|
||||
}
|
||||
|
||||
/** GET /api/v1/agents → 200 PaginatedAgents */
|
||||
public PaginatedAgents listAgents(ListAgentsParams params) {
|
||||
return http.request("GET", buildListUrl(params), null, tokenSupplier.get(), PaginatedAgents.class);
|
||||
}
|
||||
|
||||
/** GET /api/v1/agents/:id → 200 Agent */
|
||||
public Agent getAgent(String agentId) {
|
||||
return http.request("GET", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Agent.class);
|
||||
}
|
||||
|
||||
/** PATCH /api/v1/agents/:id → 200 Agent */
|
||||
public Agent updateAgent(String agentId, UpdateAgentRequest request) {
|
||||
return http.request("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, tokenSupplier.get(), Agent.class);
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/agents/:id → 204 No Content */
|
||||
public void decommissionAgent(String agentId) {
|
||||
http.request("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Void.class);
|
||||
}
|
||||
|
||||
// ─── Async ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Async version of {@link #registerAgent}. */
|
||||
public CompletableFuture<Agent> registerAgentAsync(RegisterAgentRequest request) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("POST", baseUrl + "/api/v1/agents", request, token, Agent.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #listAgents}. */
|
||||
public CompletableFuture<PaginatedAgents> listAgentsAsync(ListAgentsParams params) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("GET", buildListUrl(params), null, token, PaginatedAgents.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #getAgent}. */
|
||||
public CompletableFuture<Agent> getAgentAsync(String agentId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/agents/" + agentId, null, token, Agent.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #updateAgent}. */
|
||||
public CompletableFuture<Agent> updateAgentAsync(String agentId, UpdateAgentRequest request) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, token, Agent.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #decommissionAgent}. */
|
||||
public CompletableFuture<Void> decommissionAgentAsync(String agentId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, token, Void.class));
|
||||
}
|
||||
|
||||
// ─── URL builder ──────────────────────────────────────────────────────────
|
||||
|
||||
private String buildListUrl(ListAgentsParams params) {
|
||||
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents");
|
||||
if (params != null) {
|
||||
StringBuilder query = new StringBuilder();
|
||||
appendParam(query, "status", params.getStatus());
|
||||
appendParam(query, "agentType", params.getAgentType());
|
||||
appendParam(query, "deploymentEnv", params.getDeploymentEnv());
|
||||
if (params.getPage() != null) appendParam(query, "page", params.getPage().toString());
|
||||
if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString());
|
||||
if (query.length() > 0) url.append("?").append(query.substring(1)); // trim leading &
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder sb, String key, String value) {
|
||||
if (value != null && !value.isEmpty()) {
|
||||
sb.append("&").append(key).append("=").append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ai.sentryagent.idp.services;
|
||||
|
||||
import ai.sentryagent.idp.internal.HttpHelper;
|
||||
import ai.sentryagent.idp.models.AuditEvent;
|
||||
import ai.sentryagent.idp.models.PaginatedAuditEvents;
|
||||
import ai.sentryagent.idp.models.QueryAuditParams;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Client for the Audit Log API endpoints.
|
||||
* Provides both synchronous and asynchronous (CompletableFuture) methods.
|
||||
*/
|
||||
public final class AuditClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final Supplier<String> tokenSupplier;
|
||||
private final HttpHelper http;
|
||||
|
||||
public AuditClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/v1/audit → 200 PaginatedAuditEvents */
|
||||
public PaginatedAuditEvents queryAuditLog(QueryAuditParams params) {
|
||||
return http.request("GET", buildQueryUrl(params), null, tokenSupplier.get(), PaginatedAuditEvents.class);
|
||||
}
|
||||
|
||||
/** GET /api/v1/audit/:id → 200 AuditEvent */
|
||||
public AuditEvent getAuditEvent(String eventId) {
|
||||
return http.request("GET", baseUrl + "/api/v1/audit/" + eventId, null, tokenSupplier.get(), AuditEvent.class);
|
||||
}
|
||||
|
||||
// ─── Async ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Async version of {@link #queryAuditLog}. */
|
||||
public CompletableFuture<PaginatedAuditEvents> queryAuditLogAsync(QueryAuditParams params) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("GET", buildQueryUrl(params), null, token, PaginatedAuditEvents.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #getAuditEvent}. */
|
||||
public CompletableFuture<AuditEvent> getAuditEventAsync(String eventId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/audit/" + eventId, null, token, AuditEvent.class));
|
||||
}
|
||||
|
||||
// ─── URL builder ──────────────────────────────────────────────────────────
|
||||
|
||||
private String buildQueryUrl(QueryAuditParams params) {
|
||||
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/audit");
|
||||
StringBuilder query = new StringBuilder();
|
||||
if (params != null) {
|
||||
appendParam(query, "agentId", params.getAgentId());
|
||||
appendParam(query, "action", params.getAction());
|
||||
appendParam(query, "outcome", params.getOutcome());
|
||||
appendParam(query, "fromDate", params.getFromDate());
|
||||
appendParam(query, "toDate", params.getToDate());
|
||||
if (params.getPage() != null) appendParam(query, "page", params.getPage().toString());
|
||||
if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString());
|
||||
}
|
||||
if (query.length() > 0) url.append("?").append(query.substring(1));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder sb, String key, String value) {
|
||||
if (value != null && !value.isEmpty()) {
|
||||
sb.append("&").append(key).append("=").append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ai.sentryagent.idp.services;
|
||||
|
||||
import ai.sentryagent.idp.internal.HttpHelper;
|
||||
import ai.sentryagent.idp.models.*;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Client for the Credential Management API endpoints.
|
||||
* Provides both synchronous and asynchronous (CompletableFuture) methods.
|
||||
*/
|
||||
public final class CredentialClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final Supplier<String> tokenSupplier;
|
||||
private final HttpHelper http;
|
||||
|
||||
public CredentialClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret */
|
||||
public CredentialWithSecret generateCredential(String agentId) {
|
||||
return http.request("POST", baseUrl + "/api/v1/agents/" + agentId + "/credentials",
|
||||
null, tokenSupplier.get(), CredentialWithSecret.class);
|
||||
}
|
||||
|
||||
/** GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials */
|
||||
public PaginatedCredentials listCredentials(String agentId, Integer page, Integer limit) {
|
||||
return http.request("GET", buildListUrl(agentId, page, limit),
|
||||
null, tokenSupplier.get(), PaginatedCredentials.class);
|
||||
}
|
||||
|
||||
/** POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret */
|
||||
public CredentialWithSecret rotateCredential(String agentId, String credentialId) {
|
||||
return http.request("POST",
|
||||
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate",
|
||||
null, tokenSupplier.get(), CredentialWithSecret.class);
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential */
|
||||
public Credential revokeCredential(String agentId, String credentialId) {
|
||||
return http.request("DELETE",
|
||||
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId,
|
||||
null, tokenSupplier.get(), Credential.class);
|
||||
}
|
||||
|
||||
// ─── Async ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Async version of {@link #generateCredential}. */
|
||||
public CompletableFuture<CredentialWithSecret> generateCredentialAsync(String agentId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("POST",
|
||||
baseUrl + "/api/v1/agents/" + agentId + "/credentials",
|
||||
null, token, CredentialWithSecret.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #listCredentials}. */
|
||||
public CompletableFuture<PaginatedCredentials> listCredentialsAsync(String agentId, Integer page, Integer limit) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("GET", buildListUrl(agentId, page, limit),
|
||||
null, token, PaginatedCredentials.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #rotateCredential}. */
|
||||
public CompletableFuture<CredentialWithSecret> rotateCredentialAsync(String agentId, String credentialId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("POST",
|
||||
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate",
|
||||
null, token, CredentialWithSecret.class));
|
||||
}
|
||||
|
||||
/** Async version of {@link #revokeCredential}. */
|
||||
public CompletableFuture<Credential> revokeCredentialAsync(String agentId, String credentialId) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier)
|
||||
.thenCompose(token -> http.requestAsync("DELETE",
|
||||
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId,
|
||||
null, token, Credential.class));
|
||||
}
|
||||
|
||||
private String buildListUrl(String agentId, Integer page, Integer limit) {
|
||||
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents/" + agentId + "/credentials");
|
||||
StringBuilder query = new StringBuilder();
|
||||
if (page != null) { query.append("&page=").append(page); }
|
||||
if (limit != null) { query.append("&limit=").append(limit); }
|
||||
if (query.length() > 0) url.append("?").append(query.substring(1));
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ai.sentryagent.idp.services;
|
||||
|
||||
import ai.sentryagent.idp.AgentIdPException;
|
||||
import ai.sentryagent.idp.models.IntrospectResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Client for token introspection and revocation endpoints.
|
||||
* Uses form-encoded POST bodies (not JSON), per RFC 7009 / RFC 7662.
|
||||
*/
|
||||
public final class TokenClient {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final String baseUrl;
|
||||
private final Supplier<String> tokenSupplier;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public TokenClient(String baseUrl, Supplier<String> tokenSupplier, HttpClient httpClient) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.tokenSupplier = tokenSupplier;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
// ─── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse */
|
||||
public IntrospectResponse introspectToken(String accessToken) {
|
||||
String body = "token=" + encode(accessToken);
|
||||
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, tokenSupplier.get());
|
||||
try {
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
|
||||
}
|
||||
return MAPPER.readValue(resp.body(), IntrospectResponse.class);
|
||||
} catch (AgentIdPException e) {
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw AgentIdPException.networkError(e);
|
||||
} catch (IOException e) {
|
||||
throw AgentIdPException.networkError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/token/revoke (form-encoded) → 200 */
|
||||
public void revokeToken(String accessToken) {
|
||||
String body = "token=" + encode(accessToken);
|
||||
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, tokenSupplier.get());
|
||||
try {
|
||||
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
|
||||
}
|
||||
} catch (AgentIdPException e) {
|
||||
throw e;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw AgentIdPException.networkError(e);
|
||||
} catch (IOException e) {
|
||||
throw AgentIdPException.networkError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Async ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Async version of {@link #introspectToken}. */
|
||||
public CompletableFuture<IntrospectResponse> introspectTokenAsync(String accessToken) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> {
|
||||
String body = "token=" + encode(accessToken);
|
||||
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, token);
|
||||
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(resp -> {
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(resp.body(), IntrospectResponse.class);
|
||||
} catch (IOException e) {
|
||||
throw new AgentIdPException("PARSE_ERROR", "Failed to parse introspect response: " + e.getMessage(), resp.statusCode());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Async version of {@link #revokeToken}. */
|
||||
public CompletableFuture<Void> revokeTokenAsync(String accessToken) {
|
||||
return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> {
|
||||
String body = "token=" + encode(accessToken);
|
||||
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, token);
|
||||
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(resp -> {
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
|
||||
}
|
||||
return (Void) null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private HttpRequest buildFormRequest(String url, String formBody, String token) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(formBody))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import ai.sentryagent.idp.models.Agent;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentIdPClientTest {
|
||||
|
||||
private MockServer srv;
|
||||
|
||||
private static final String TOKEN_BODY =
|
||||
"{\"access_token\":\"integration-token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"scope\":\"agents:read agents:write tokens:read audit:read\"}";
|
||||
|
||||
private static final String AGENT_JSON =
|
||||
"{\"agentId\":\"uuid-1\",\"email\":\"a@b.ai\",\"agentType\":\"screener\",\"version\":\"1.0.0\"," +
|
||||
"\"capabilities\":[\"read\"],\"owner\":\"team\",\"deploymentEnv\":\"production\"," +
|
||||
"\"status\":\"active\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"updatedAt\":\"2026-01-01T00:00:00Z\"}";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
srv = new MockServer();
|
||||
// Register token endpoint for every test (each test gets a fresh MockServer)
|
||||
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() { srv.stop(); }
|
||||
|
||||
private AgentIdPClient makeClient() {
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
return new AgentIdPClient(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgent_endToEnd() {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
AgentIdPClient client = makeClient();
|
||||
Agent agent = client.agents().getAgent("uuid-1");
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
assertEquals("screener", agent.getAgentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceClients_areAccessible() {
|
||||
AgentIdPClient client = makeClient();
|
||||
assertNotNull(client.agents());
|
||||
assertNotNull(client.credentials());
|
||||
assertNotNull(client.tokens());
|
||||
assertNotNull(client.audit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearTokenCache_forcesRefetch() throws IOException {
|
||||
// Dedicated MockServer so we control the token counter from scratch
|
||||
MockServer dedicated = new MockServer();
|
||||
AtomicInteger tokenCalls = new AtomicInteger(0);
|
||||
dedicated.addHandler("/api/v1/token", exchange -> {
|
||||
tokenCalls.incrementAndGet();
|
||||
try {
|
||||
byte[] body = TOKEN_BODY.getBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.getResponseBody().close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
|
||||
try {
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
client.agents().getAgent("uuid-1");
|
||||
client.clearTokenCache();
|
||||
client.agents().getAgent("uuid-1");
|
||||
assertEquals(2, tokenCalls.get(), "Token should be refetched after clearTokenCache");
|
||||
} finally {
|
||||
dedicated.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultScope_containsAllFourScopes() throws IOException {
|
||||
MockServer dedicated = new MockServer();
|
||||
StringBuilder capturedBody = new StringBuilder();
|
||||
dedicated.addHandler("/api/v1/token", exchange -> {
|
||||
try {
|
||||
String body = new String(exchange.getRequestBody().readAllBytes());
|
||||
capturedBody.append(body);
|
||||
byte[] resp = TOKEN_BODY.getBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, resp.length);
|
||||
exchange.getResponseBody().write(resp);
|
||||
exchange.getResponseBody().close();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
|
||||
try {
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
// Two-arg constructor → default scope applied
|
||||
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret",
|
||||
"agents:read agents:write tokens:read audit:read", httpClient);
|
||||
client.agents().getAgent("uuid-1");
|
||||
String captured = capturedBody.toString();
|
||||
assertTrue(captured.contains("agents"), "Scope should be present in token request body: " + captured);
|
||||
} finally {
|
||||
dedicated.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentIdPExceptionTest {
|
||||
|
||||
@Test
|
||||
void constructor_setsFields() {
|
||||
AgentIdPException ex = new AgentIdPException("AgentNotFoundError", "Not found.", 404);
|
||||
assertEquals("AgentNotFoundError", ex.getCode());
|
||||
assertEquals("Not found.", ex.getMessage());
|
||||
assertEquals(404, ex.getHttpStatus());
|
||||
assertNull(ex.getDetails());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromApiError_validBody() {
|
||||
String body = "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}";
|
||||
AgentIdPException ex = AgentIdPException.fromApiError(body, 404);
|
||||
assertEquals("AgentNotFoundError", ex.getCode());
|
||||
assertEquals("Not found.", ex.getMessage());
|
||||
assertEquals(404, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromApiError_emptyCode_fallsBackToUnknown() {
|
||||
String body = "{\"message\":\"oops\"}";
|
||||
AgentIdPException ex = AgentIdPException.fromApiError(body, 503);
|
||||
assertEquals("UNKNOWN_ERROR", ex.getCode());
|
||||
assertEquals(503, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromApiError_unparseable_fallsBackToUnknown() {
|
||||
AgentIdPException ex = AgentIdPException.fromApiError("not json", 500);
|
||||
assertEquals("UNKNOWN_ERROR", ex.getCode());
|
||||
assertEquals(500, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromOAuth2Error_validBody() {
|
||||
String body = "{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}";
|
||||
AgentIdPException ex = AgentIdPException.fromOAuth2Error(body, 401);
|
||||
assertEquals("invalid_client", ex.getCode());
|
||||
assertEquals("Bad credentials.", ex.getMessage());
|
||||
assertEquals(401, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromOAuth2Error_unparseable_fallsBackToUnknown() {
|
||||
AgentIdPException ex = AgentIdPException.fromOAuth2Error("garbage", 400);
|
||||
assertEquals("unknown_error", ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void networkError_setsCodeAndCause() {
|
||||
RuntimeException cause = new RuntimeException("connection refused");
|
||||
AgentIdPException ex = AgentIdPException.networkError(cause);
|
||||
assertEquals("NETWORK_ERROR", ex.getCode());
|
||||
assertEquals(0, ex.getHttpStatus());
|
||||
assertSame(cause, ex.getCause());
|
||||
assertTrue(ex.getMessage().contains("connection refused"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toString_containsCodeAndStatus() {
|
||||
AgentIdPException ex = new AgentIdPException("CODE", "msg", 400);
|
||||
assertTrue(ex.toString().contains("CODE"));
|
||||
assertTrue(ex.toString().contains("400"));
|
||||
}
|
||||
}
|
||||
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal file
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal file
@@ -0,0 +1,73 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Lightweight in-process HTTP server for unit tests.
|
||||
* Uses the JDK's built-in {@code com.sun.net.httpserver.HttpServer}.
|
||||
*/
|
||||
public final class MockServer {
|
||||
|
||||
private final HttpServer server;
|
||||
private final int port;
|
||||
|
||||
public MockServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
server.start();
|
||||
port = server.getAddress().getPort();
|
||||
}
|
||||
|
||||
/** Base URL of the mock server (e.g. {@code "http://localhost:PORT"}). */
|
||||
public String baseUrl() {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for an exact path.
|
||||
*
|
||||
* @param path URL path (e.g. {@code "/api/v1/agents"})
|
||||
* @param statusCode HTTP status code to return
|
||||
* @param responseBody JSON body to return (may be null for empty body)
|
||||
*/
|
||||
public void addHandler(String path, int statusCode, String responseBody) {
|
||||
server.createContext(path, new StaticHandler(statusCode, responseBody));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom handler for an exact path.
|
||||
*/
|
||||
public void addHandler(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler);
|
||||
}
|
||||
|
||||
/** Stops the server. */
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static final class StaticHandler implements HttpHandler {
|
||||
private final int statusCode;
|
||||
private final byte[] body;
|
||||
|
||||
StaticHandler(int statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(statusCode, body.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal file
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal file
@@ -0,0 +1,102 @@
|
||||
package ai.sentryagent.idp;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TokenManagerTest {
|
||||
|
||||
private MockServer srv;
|
||||
private HttpClient httpClient;
|
||||
|
||||
private static final String TOKEN_BODY = """
|
||||
{"access_token":"eyJ.abc.def","token_type":"Bearer","expires_in":3600,"scope":"agents:read"}
|
||||
""";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
srv = new MockServer();
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() { srv.stop(); }
|
||||
|
||||
@Test
|
||||
void getToken_issuesToken() {
|
||||
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
|
||||
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
assertEquals("eyJ.abc.def", tm.getToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getToken_cachesToken() {
|
||||
AtomicInteger calls = new AtomicInteger(0);
|
||||
srv.addHandler("/api/v1/token", exchange -> {
|
||||
calls.incrementAndGet();
|
||||
byte[] body = TOKEN_BODY.getBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
tm.getToken();
|
||||
tm.getToken();
|
||||
assertEquals(1, calls.get(), "Should only call the token endpoint once");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getToken_authFailure_throwsAgentIdPException() {
|
||||
srv.addHandler("/api/v1/token", 401,
|
||||
"{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}");
|
||||
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "bad-secret", "agents:read", httpClient);
|
||||
AgentIdPException ex = assertThrows(AgentIdPException.class, tm::getToken);
|
||||
assertEquals("invalid_client", ex.getCode());
|
||||
assertEquals(401, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearCache_forcesRefetch() {
|
||||
AtomicInteger calls = new AtomicInteger(0);
|
||||
srv.addHandler("/api/v1/token", exchange -> {
|
||||
calls.incrementAndGet();
|
||||
byte[] body = TOKEN_BODY.getBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.getResponseBody().close();
|
||||
});
|
||||
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
tm.getToken();
|
||||
tm.clearCache();
|
||||
tm.getToken();
|
||||
assertEquals(2, calls.get(), "Should call token endpoint again after clearCache");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getToken_threadSafe() throws InterruptedException {
|
||||
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
|
||||
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
|
||||
|
||||
Thread[] threads = new Thread[10];
|
||||
String[] results = new String[10];
|
||||
for (int i = 0; i < threads.length; i++) {
|
||||
int idx = i;
|
||||
threads[idx] = new Thread(() -> results[idx] = tm.getToken());
|
||||
}
|
||||
for (Thread t : threads) t.start();
|
||||
for (Thread t : threads) t.join();
|
||||
|
||||
for (String result : results) {
|
||||
assertEquals("eyJ.abc.def", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package ai.sentryagent.idp.services;
|
||||
|
||||
import ai.sentryagent.idp.AgentIdPException;
|
||||
import ai.sentryagent.idp.MockServer;
|
||||
import ai.sentryagent.idp.internal.HttpHelper;
|
||||
import ai.sentryagent.idp.models.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentRegistryClientTest {
|
||||
|
||||
private MockServer srv;
|
||||
private AgentRegistryClient client;
|
||||
|
||||
private static final String AGENT_JSON = """
|
||||
{"agentId":"uuid-1","email":"a@b.ai","agentType":"screener","version":"1.0.0",
|
||||
"capabilities":["read"],"owner":"team","deploymentEnv":"production",
|
||||
"status":"active","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"}
|
||||
""";
|
||||
|
||||
private static final String PAGINATED_AGENTS = """
|
||||
{"data":[%s],"total":1,"page":1,"limit":20}
|
||||
""".formatted(AGENT_JSON.strip());
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
srv = new MockServer();
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
HttpHelper httpHelper = new HttpHelper(httpClient);
|
||||
client = new AgentRegistryClient(srv.baseUrl(), () -> "test-token", httpHelper);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() { srv.stop(); }
|
||||
|
||||
@Test
|
||||
void registerAgent_returns201() {
|
||||
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
|
||||
Agent agent = client.registerAgent(RegisterAgentRequest.builder()
|
||||
.email("a@b.ai").agentType("screener").version("1.0.0")
|
||||
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
|
||||
.build());
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
assertEquals("screener", agent.getAgentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAgents_returnsPaginated() {
|
||||
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
|
||||
PaginatedAgents result = client.listAgents(null);
|
||||
assertEquals(1, result.getTotal());
|
||||
assertEquals("uuid-1", result.getData().get(0).getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgent_returnsAgent() {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
Agent agent = client.getAgent("uuid-1");
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgent_notFound_throwsAgentIdPException() {
|
||||
srv.addHandler("/api/v1/agents/bad-id", 404,
|
||||
"{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}");
|
||||
AgentIdPException ex = assertThrows(AgentIdPException.class, () -> client.getAgent("bad-id"));
|
||||
assertEquals("AgentNotFoundError", ex.getCode());
|
||||
assertEquals(404, ex.getHttpStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAgent_returnsUpdated() {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
Agent agent = client.updateAgent("uuid-1",
|
||||
UpdateAgentRequest.builder().version("2.0.0").build());
|
||||
assertNotNull(agent);
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void decommissionAgent_returns204() {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
|
||||
assertDoesNotThrow(() -> client.decommissionAgent("uuid-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAgentAsync_returnsCompletableFuture() throws Exception {
|
||||
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
|
||||
Agent agent = client.registerAgentAsync(RegisterAgentRequest.builder()
|
||||
.email("a@b.ai").agentType("screener").version("1.0.0")
|
||||
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
|
||||
.build()).get();
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAgentAsync_returnsCompletableFuture() throws Exception {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
Agent agent = client.getAgentAsync("uuid-1").get();
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAgentsAsync_withParams() throws Exception {
|
||||
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
|
||||
PaginatedAgents result = client.listAgentsAsync(
|
||||
ListAgentsParams.builder().status("active").page(1).limit(20).build()
|
||||
).get();
|
||||
assertEquals(1, result.getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void decommissionAgentAsync_completesSuccessfully() throws Exception {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
|
||||
assertDoesNotThrow(() -> client.decommissionAgentAsync("uuid-1").get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAgentAsync_returnsCompletableFuture() throws Exception {
|
||||
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
|
||||
Agent agent = client.updateAgentAsync("uuid-1",
|
||||
UpdateAgentRequest.builder().version("2.0.0").build()).get();
|
||||
assertEquals("uuid-1", agent.getAgentId());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user