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:
SentryAgent.ai Developer
2026-03-29 06:27:09 +00:00
193 changed files with 19812 additions and 79 deletions

95
dashboard/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

29
dashboard/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

33
dashboard/src/App.tsx Normal file
View 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>
);
}

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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;
}

View 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,
});
}

View 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
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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: [],
};

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
});

View 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
View 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: ~5060 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 2030 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: ~3545 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 1520 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 6090 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.

View File

@@ -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`.

View File

@@ -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
View 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).

View 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": ""
}

View 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

View File

@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View 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

View File

@@ -0,0 +1,3 @@
change: phase-2-production-ready
status: proposed
date: 2026-03-28

View 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 │
└─────────────────┘
```

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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
View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
module github.com/sentryagent/idp-sdk-go
go 1.21

79
sdk-go/request.go Normal file
View 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
View 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
}

View 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
View 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
}

View 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
View 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
View File

@@ -0,0 +1 @@
target/

190
sdk-java/README.md Normal file
View 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
View 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>

View File

@@ -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(); }
}

View File

@@ -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() + "'}";
}
}

View 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);
}
}

View File

@@ -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);
}
}
}

View 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 + "'}";
}
}

View File

@@ -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 + "'}";
}
}

View File

@@ -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 + "'}";
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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); }
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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); }
}
}

View File

@@ -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); }
}
}

View File

@@ -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; }
}

View File

@@ -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); }
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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"));
}
}

View 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);
}
}
}
}

View 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);
}
}
}

View File

@@ -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