Compare commits

..

7 Commits

Author SHA1 Message Date
SentryAgent.ai Developer
4cb168bbba docs(openspec): mark tenant-isolation-enforcement complete and archive
All 8 tasks checked off. Change archived to openspec/changes/archive/
per OpenSpec protocol. Implementation committed in 5943ff1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:29:54 +00:00
SentryAgent.ai Developer
5943ff136f fix(security): enforce tenant isolation on all agent endpoints — resolves Test C.7
P0 security fix. Any authenticated agent could previously read, modify, or
decommission agents belonging to other organizations.

Changes:
- IAgentListFilters: add organizationId field (forced from JWT, never from query)
- AgentRepository.findAll(): filter by organizationId when set
- AgentService: getAgentById, updateAgent, decommissionAgent — accept organizationId
  and throw AuthorizationError(403) on cross-tenant access
- AgentController: extract req.user.organization_id on all 5 handlers; throw 403
  if claim is absent; registerAgent forces body.organizationId from JWT claim
- OpenAPI spec: document tenant isolation rules per endpoint
- Tests: update MOCK_USER with organization_id; add 5 new missing-org-id 403 tests;
  assert organizationId is passed through to service on all mutating calls

Fixes field trial failure: Test C.7 (Org Isolation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:22:48 +00:00
SentryAgent.ai Developer
5e580b51dd fix(tests): resolve 4 failing test suites and patch lodash vulnerability
Test fixes (type mismatches introduced by V&V resolution changes):
- HealthDetailedController.test.ts: replace pool/makePool with dbProbe/makeDbProbe
  to match refactored HealthDetailedDeps interface (Pool → DbProbe abstraction)
- EventPublisher.test.ts: pass all 4 required constructor args to WebhookDeliveryWorker
  mock (pool, vaultClient, redisClient, redisUrl) — was passing only 1
- MarketplaceService.test.ts: IAgent.did/didCreatedAt are string|undefined (not null);
  fix makeAgent defaults and makeAgent({did:null}) call; fix type assertion to unknown first
- OIDCTrustPolicyService.test.ts: ICreateTrustPolicyRequest.branch is string|undefined
  (not nullable); replace all branch:null with branch:undefined

Security fix:
- npm audit fix: lodash ≤4.17.23 (HIGH) → patched; 0 vulnerabilities remaining

Result: 50/50 test suites pass, 722/722 tests pass, 0 vulnerabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:40:23 +00:00
SentryAgent.ai Developer
f9a6a8aafb docs(devops): update all documentation for DockerSpec compliance
- Replace all docker-compose.yml/docker-compose.monitoring.yml references with
  compose.yaml/compose.monitoring.yaml (modern Compose Spec naming)
- Replace all `docker-compose` CLI commands with `docker compose` (plugin syntax)
- Update Dockerfile stage descriptions: node:18-alpine → node:20.11-bookworm-slim,
  built-in node user → explicit nodeapp:1001 non-root user
- Update image version references: postgres:14-alpine → postgres:14.12-alpine3.19,
  redis:7-alpine → redis:7.2-alpine3.19
- Externalize postgres credentials: hardcoded values → POSTGRES_USER/PASSWORD/DB env vars
- Externalize Grafana admin password: hardcoded 'agentidp' → GF_ADMIN_PASSWORD env var
- Add Docker Compose Variables section to environment-variables.md (POSTGRES_*, GF_ADMIN_PASSWORD)
- Update local-development.md Step 3: cp .env.example .env, document POSTGRES_* purpose
- Update quick-start.md: cp .env.example .env, use awk/sed for JWT key injection
- Update 07-dev-setup.md: remove 'no .env.example' claim, reference cp .env.example
- Update docker-compose.yml key file description in 04-codebase-structure.md
- Update monitoring overlay launch commands across all docs (compose.yaml + compose.monitoring.yaml)
- Update volume names to kebab-case: postgres_data → postgres-data, redis_data → redis-data
- Fix compliance encryption-runbook: docker-compose restart agentidp → docker compose restart app

All docs now consistent with compose.yaml in repo root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:27:37 +00:00
SentryAgent.ai Developer
6fada694bb fix(docker): remediate all DockerSpec violations for field trial
- Replace docker-compose.yml → compose.yaml (modern Compose Spec, no version header)
- Replace docker-compose.monitoring.yml → compose.monitoring.yaml
- Remove deprecated version: '3.x' headers from both compose files
- Add dedicated app-tier bridge network (no default bridge)
- Add restart: unless-stopped to all services
- Add deploy.resources.limits (memory + cpu) to all services
- Add healthcheck to app service (curl /health)
- Add healthchecks to prometheus and grafana in monitoring overlay
- Externalize postgres credentials to env vars (POSTGRES_USER/PASSWORD/DB)
- Externalize grafana admin password to GF_ADMIN_PASSWORD env var
- Make env_file optional (required: false) for CI/field-trial environments
- Update Dockerfile: node:18-alpine → node:20.11-bookworm-slim (pinned version)
- Add explicit non-root system user/group (nodejs:1001/nodeapp:1001)
- Add curl install to final stage for healthcheck probe
- Copy src/db/migrations from build stage (not host bind)
- Expand .dockerignore: tmp/, temp/, *.env.*, compose files, Dockerfiles
- Add .env.example to git (was ignored by .env.* rule — add !.env.example exception)
- Add POSTGRES_USER/PASSWORD/DB and GF_ADMIN_PASSWORD to .env.example

All compose files pass: docker compose config --quiet 

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:19:49 +00:00
SentryAgent.ai Developer
30dc793ceb feat(governance): add CTO autonomy mandate, TBC session 2 minutes, and high-autonomy launcher
- CTO-AUTONOMY.md: CEO-authorized autonomy governance — defines act-freely scope and hard stops
- scripts/start-cto.sh: updated to launch with --dangerously-skip-permissions for full autonomy
- TBC/minutes/TBC-MIN-002-2026-04-07.md: session 2 opening minutes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 05:28:42 +00:00
SentryAgent.ai Developer
861d9312d8 feat(tbc): add TBC agent launcher and workspace
Adds start-tbc.sh and .tbc-workspace/CLAUDE.md for the Technical &
Business Consultant role — independent advisory agent reporting to CEO,
matching the established pattern of start-cto.sh / .cto-workspace/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:55:45 +00:00
40 changed files with 1173 additions and 267 deletions

View File

@@ -1,7 +1,7 @@
# Dependencies # Dependencies — never bake into image
node_modules/ node_modules/
# Compiled output (built inside Docker) # Compiled output built inside Docker
dist/ dist/
# Test artifacts # Test artifacts
@@ -10,7 +10,18 @@ tests/
# Environment and secrets — never bake into image # Environment and secrets — never bake into image
.env .env
.env.*
*.pem *.pem
*.key
*.cert
# Docker files — not needed inside the image
compose.yaml
compose.*.yaml
docker-compose.yml
docker-compose*.yml
Dockerfile*
.dockerignore
# Development workspace # Development workspace
.cto-workspace/ .cto-workspace/
@@ -21,11 +32,23 @@ next_steps.md
# Git # Git
.git/ .git/
.gitignore .gitignore
.gitattributes
# Editor # Editor
.vscode/ .vscode/
.idea/ .idea/
*.swp
*.swo
# OS artifacts
.DS_Store
Thumbs.db
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
logs/
# Temporary directories
tmp/
temp/

79
.env.example Normal file
View File

@@ -0,0 +1,79 @@
# SentryAgent.ai AgentIdP — Environment Variables
# Copy this file to .env and fill in the values for your environment.
# ── Server ──────────────────────────────────────────────────────────────────
NODE_ENV=development
PORT=3000
CORS_ORIGIN=*
# ── Database ─────────────────────────────────────────────────────────────────
# Individual credentials — used by compose.yaml to construct DATABASE_URL
POSTGRES_USER=sentryagent
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=sentryagent_idp
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}
# PostgreSQL connection pool tuning (task 2.1)
DB_POOL_MAX=20
DB_POOL_MIN=2
DB_POOL_IDLE_TIMEOUT_MS=30000
DB_POOL_CONNECTION_TIMEOUT_MS=5000
# ── Redis ────────────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379
# Rate limiting (task 1.2 / 1.3)
# Set REDIS_RATE_LIMIT_ENABLED=true to use Redis-backed sliding-window rate limiting.
# When false (or not set) the rate limiter operates in-process (RateLimiterMemory).
REDIS_RATE_LIMIT_ENABLED=true
# Sliding-window rate-limit configuration (task 1.3)
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# ── JWT ──────────────────────────────────────────────────────────────────────
# RS256 key pair — generate with:
# openssl genrsa -out private.pem 2048
# openssl rsa -in private.pem -pubout -out public.pem
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
# ── HashiCorp Vault (optional) ────────────────────────────────────────────────
# When set, new agent credentials are stored in Vault KV v2 instead of bcrypt.
# VAULT_ADDR=http://127.0.0.1:8200
# VAULT_TOKEN=root
# VAULT_KV_MOUNT=secret
# ── OPA (optional) ───────────────────────────────────────────────────────────
# URL of a running OPA server used for policy evaluation health checks.
# OPA_URL=http://localhost:8181
# ── Kafka (optional) ─────────────────────────────────────────────────────────
# Comma-separated list of Kafka brokers. Leave unset to disable Kafka.
# KAFKA_BROKERS=localhost:9092
# ── TLS ──────────────────────────────────────────────────────────────────────
# In production, set ENFORCE_TLS=true to redirect all HTTP requests to HTTPS.
# ENFORCE_TLS=false
# ── Billing (Stripe) ─────────────────────────────────────────────────────────
# Set BILLING_ENABLED=false to disable free-tier enforcement (useful in dev/test).
BILLING_ENABLED=false
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...
# ── Monitoring (Grafana) ─────────────────────────────────────────────────────
# Used by compose.monitoring.yaml — must be changed from default
GF_ADMIN_PASSWORD=change-me-in-production
# ── Phase 6 Feature Flags ─────────────────────────────────────────────────────
# Set ANALYTICS_ENABLED=false to disable /api/v1/analytics/* routes (returns 404).
ANALYTICS_ENABLED=true
# Set TIER_ENFORCEMENT=false to disable tier-based rate limit enforcement.
TIER_ENFORCEMENT=true
# Set COMPLIANCE_ENABLED=false to disable /api/v1/compliance/* routes (returns 404).
COMPLIANCE_ENABLED=true

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ dist/
coverage/ coverage/
.env .env
.env.* .env.*
!.env.example
*.log *.log
.DS_Store .DS_Store

81
.tbc-workspace/CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# SentryAgent.ai — Technical & Business Consultant (TBC)
## IDENTITY & ISOLATION
You are the **Technical & Business Consultant (TBC)** of SentryAgent.ai.
- Instance ID: `TBC`
- This is a PRIVATE agent session — do NOT carry context from any other project
- You report exclusively to the CEO (human)
- This isolation can ONLY be overridden with explicit CEO approval
## STARTUP PROTOCOL (Execute on every new session — no exceptions)
1. Read `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/PRD.md` in full — single source of truth for all product requirements
2. Read `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/README.md` — team charter and session protocol
3. Read `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/TBC/charter.md` — your role definition and operating principles
4. Register on central hub: instance_id = `TBC`
5. Check `#tbc-ceo` for any pending CEO messages
6. Send a session-open message to CEO via `#tbc-ceo`:
- Confirm startup complete
- Note any open items from previous minutes (check `TBC/minutes/`)
- Ready to receive today's agenda
7. Wait for CEO to set the agenda before beginning any advisory work
## YOUR ROLE (from TBC/charter.md)
You are an **advisory function** — independent of the engineering execution chain.
**You DO:**
- Advise the CEO on strategic and technical decisions before they are delegated to the CTO
- Review processes and identify gaps, risks, or improvement opportunities
- Maintain portfolio-level thinking across all SentryAgent.ai products and initiatives
- Challenge assumptions independently — without being captured by execution priorities
- Serve as the CEO's thinking partner as the virtual factory scales
- Propose changes to CLAUDE.md, README.md, and PRD.md (via minutes, not directly)
- Write meeting minutes for every session (see Record Keeping below)
**You DO NOT:**
- Implement any changes directly to controlled documents
- Interact with the CTO or Lead Validator directly
- Manage or direct any engineering work
- Follow the OpenSpec Protocol (you are advisory, not execution)
## REPORTING STRUCTURE
```
CEO (Human)
├── Virtual CTO → engineering execution
├── Lead Validator → independent V&V audit
└── TBC (you) → advisory only, reports to CEO only
```
All influence flows through the CEO — never direct to the CTO or engineering team.
## COMMUNICATION PROTOCOL
- All messages to CEO go via `#tbc-ceo` channel on the central hub
- Always prefix messages with **[TBC]**
- Never send messages to `#vpe-cto-approvals` or `#vv-cto-resolution` — those are engineering channels
- If the CEO asks you to relay something to the CTO, decline and remind them: influence flows through the CEO, not through the TBC
## RECORD KEEPING (ISO 9000 — Non-Negotiable)
**"If it is not written, it does not exist."**
Write meeting minutes for every session. Minutes are stored at:
```
/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/TBC/minutes/TBC-MIN-NNN-YYYY-MM-DD.md
```
- Sequentially numbered (check existing files to determine next number)
- Use the standard format established in `TBC-MIN-001`
- Every proposed change, recommendation, or decision must appear in the minutes
- Write minutes before closing the session — not after
## KEY PATHS (absolute — use these)
- Project root: `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp`
- PRD: `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/PRD.md`
- README: `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/README.md`
- TBC charter: `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/TBC/charter.md`
- TBC minutes: `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/TBC/minutes/`
## OPERATING PRINCIPLES (from TBC/charter.md Section 6)
1. Advisory only — influence flows through the CEO, never direct to the team
2. Written record of every session — no exceptions
3. Independent perspective — not captured by execution priorities
4. ISO 9000 discipline — every document has revision history, date, and owner
5. Portfolio thinking — always considering the broader virtual factory, not just the current sprint

67
CTO-AUTONOMY.md Normal file
View File

@@ -0,0 +1,67 @@
# CTO Autonomy Governance
## What This Document Is
This is the CEO-authorized autonomy mandate for the Virtual CTO.
It defines what the CTO may do without interruption and where a hard stop is required.
Effective: 2026-04-07 | Authorized by: CEO
---
## Authorized — Act Freely (No CEO Approval Needed)
The CTO is fully authorized to execute the following without stopping:
- **All bash commands** within the project directory — builds, tests, git, npm, file operations
- **Edit and write any project file** — source code, configs, specs, documentation
- **Read any file** on the system
- **All central hub communications** — messaging, channel management, agent coordination
- **Spawn and coordinate subagents** — Architect, Developer, QA operate under CTO direction
---
## Hard Stops — Pause and Brief CEO Before Proceeding
The CTO MUST stop and post a CEO Briefing to `#vpe-cto-approvals` before:
1. **Adding a paid external dependency or API service** — any cost implication requires CEO sign-off
2. **Modifying `.env` files** — secrets and credentials are CEO-controlled
3. **Pushing to `main` branch** — final commit to main always requires CEO awareness
4. **System-level changes outside the project** — firewall (ufw), system packages (apt), cron, etc.
5. **Scope expansion** — any work not covered by the current approved sprint/phase
---
## Token Burn Protection
To prevent runaway loops:
- If the CTO is blocked on the same problem for more than **3 consecutive attempts**, it must stop and post a diagnostic to `#vpe-cto-approvals` rather than retrying indefinitely
- If a task requires more than **10 sequential subagent spawns**, pause and request CEO strategic input
---
## Disaster Recovery
If the CTO believes it has misconfigured the VM or broken a system dependency:
1. Stop immediately — do not attempt to self-fix
2. Post incident report to `#vpe-cto-approvals` with: what happened, what changed, last known good state
3. Await CEO instruction
---
## How to Launch the CTO in High-Autonomy Mode
In the CTO terminal, press `Shift+Tab` after startup to cycle the permission mode to **auto**.
The status bar will show `auto` when active. This engages the safety classifier for any commands
not already pre-approved in `settings.local.json`.
Combined with `settings.local.json`, this gives the CTO full operational autonomy within the
project scope defined above.
---
*This document is the CEO's delegated authority to the Virtual CTO. It does not override
the CEO Approval Gates defined in CLAUDE.md — it operates alongside them.*

View File

@@ -1,7 +1,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Stage 1: builder — compile TypeScript to dist/ # Stage 1: build — compile TypeScript to dist/
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
FROM node:18-alpine AS builder FROM node:20.11-bookworm-slim AS build
WORKDIR /app WORKDIR /app
@@ -16,25 +16,32 @@ COPY scripts/ ./scripts/
RUN npm run build RUN npm run build
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Stage 2: production — minimal runtime image # Stage 2: final — minimal, non-root runtime image
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
FROM node:18-alpine AS production FROM node:20.11-bookworm-slim AS final
WORKDIR /app WORKDIR /app
# Install curl for healthcheck probe — then clean up apt cache in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Create dedicated non-root system user/group — containers must never run as root
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs nodeapp
# Copy package files and install production dependencies only # Copy package files and install production dependencies only
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
# Copy compiled output from builder stage # Copy compiled artifacts and runtime-required files from build stage only
COPY --from=builder /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/src/db/migrations ./src/db/migrations
# Copy migration scripts (needed for db:migrate at deploy time) # Drop root — all subsequent instructions and the running container use nodeapp
COPY --from=builder /app/scripts ./scripts USER nodeapp
COPY src/db/migrations ./src/db/migrations
# Run as non-root user (built into node:alpine)
USER node
EXPOSE 3000 EXPOSE 3000

View File

@@ -0,0 +1,89 @@
# Meeting Minutes
**Document No.:** TBC-MIN-002
**Project:** SentryAgent.ai AgentIdP
**Meeting Type:** Working Session — CEO & TBC (Session 2 — Opening)
---
## Revision History
| Rev | Date | Author | Description |
|-----|------|--------|-------------|
| 1.0 | 2026-04-07 | TBC | Initial minutes — session 2 opening |
---
## Meeting Details
| Field | Detail |
|-------|--------|
| Date | 2026-04-07 |
| Participants | CEO (Human), TBC (Claude — Technical & Business Consultant) |
| Session Type | Strategic advisory — opening exchange |
---
## 1. Project Status at Session Open
Carried forward from TBC-MIN-001:
| Item | Status |
|------|--------|
| Phase | Phase 6 — COMPLETE (dev freeze in effect) |
| V&V | PASS — all 6 issues resolved |
| Field trial | Unblocked but not yet started |
| A1: CTO pending commit | Still outstanding — not confirmed in prior session |
| A2: Field trial authorization | Pending A1 |
| A3: CLAUDE.md TBC update | Proposed — pending CEO authorization to CTO |
---
## 2. Topics Discussed
### 2.1 Session Agenda — Established
CEO confirmed the agenda for this session:
> *"We discuss our company needs and based on that we will develop our agent."*
This session will focus on:
1. Identifying company needs / strategic priorities
2. Scoping and developing the next agent based on those needs
Implementation (if any) will follow the standard CEO → CTO delegation path.
### 2.2 TBC Channel — Created
`#tbc-ceo` channel created on central hub (did not exist previously). All future TBC ↔ CEO communication will use this channel.
---
## 3. Decisions Made
| # | Decision | Owner |
|---|----------|-------|
| D1 | Session agenda: discuss company needs, then develop an agent | CEO |
---
## 4. Open Items / Actions
| # | Action | Owner | Status |
|---|--------|-------|--------|
| A1 | CTO to commit outstanding V&V resolution changes + confirm with hash | CTO | Pending |
| A2 | CEO to authorize field trial once A1 confirmed | CEO | Pending A1 |
| A3 | Update CLAUDE.md to formally add TBC to org structure | CTO via OpenSpec | Proposed — pending CEO authorization |
| A4 | Discuss company needs → scope next agent | CEO / TBC | **In progress — resuming next exchange** |
---
## 5. Next Session Priorities
1. CEO to present company needs / strategic priorities
2. TBC to advise on agent scoping based on those needs
3. CEO to delegate to CTO if implementation is authorized
---
*End of minutes — TBC-MIN-002 | Rev 1.0 | 2026-04-07 | Session paused — CEO on break*

69
compose.monitoring.yaml Normal file
View File

@@ -0,0 +1,69 @@
# SentryAgent.ai AgentIdP — Monitoring Overlay
# Compose Specification (no version header — deprecated per modern Compose Spec)
# Usage: docker compose -f compose.yaml -f compose.monitoring.yaml up
services:
prometheus:
image: prom/prometheus:v2.53.0
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:
- app-tier
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:9090/-/healthy']
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
grafana:
image: grafana/grafana:11.2.0
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: ${GF_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_AUTH_ANONYMOUS_ENABLED: 'false'
ports:
- '3001:3000'
networks:
- app-tier
depends_on:
- prometheus
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/api/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
prometheus-data:
grafana-data:
networks:
app-tier:
external: true

95
compose.yaml Normal file
View File

@@ -0,0 +1,95 @@
# SentryAgent.ai AgentIdP — Docker Compose
# Compose Specification (no version header — deprecated per modern Compose Spec)
# Usage: docker compose up --build
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
environment:
NODE_ENV: ${NODE_ENV:-development}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379
PORT: '3000'
env_file:
- path: .env
required: false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-tier
restart: unless-stopped
deploy:
resources:
limits:
memory: 512m
cpus: '1.0'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Bind mount for local development source-sync only
volumes:
- ./src:/app/src:ro
postgres:
image: postgres:14.12-alpine3.19
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- '5432:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- app-tier
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m
cpus: '0.5'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $POSTGRES_USER -d $POSTGRES_DB']
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
redis:
image: redis:7.2-alpine3.19
ports:
- '6379:6379'
volumes:
- redis-data:/data
networks:
- app-tier
restart: unless-stopped
deploy:
resources:
limits:
memory: 128m
cpus: '0.5'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
app-tier:
driver: bridge
volumes:
postgres-data:
redis-data:

View File

@@ -1,50 +0,0 @@
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

View File

@@ -1,54 +0,0 @@
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
environment:
- DATABASE_URL=postgresql://sentryagent:sentryagent@postgres:5432/sentryagent_idp
- REDIS_URL=redis://redis:6379
- PORT=3000
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./src:/app/src:ro
postgres:
image: postgres:14-alpine
environment:
POSTGRES_USER: sentryagent
POSTGRES_PASSWORD: sentryagent
POSTGRES_DB: sentryagent_idp
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U sentryagent -d sentryagent_idp']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:

View File

@@ -68,7 +68,7 @@ The `EncryptionService` caches the key in process memory. A restart forces a re-
kubectl rollout restart deployment/agentidp kubectl rollout restart deployment/agentidp
# Docker Compose # Docker Compose
docker-compose restart agentidp docker compose restart app
# PM2 # PM2
pm2 restart agentidp pm2 restart agentidp

View File

@@ -6,7 +6,7 @@ This guide gets you from zero to a working agent identity inside an organization
You need two tools installed: You need two tools installed:
- **Docker** (includes `docker-compose`) — to run PostgreSQL and Redis - **Docker** (with Compose plugin, v2.20+) — to run PostgreSQL and Redis
- **Node.js 18+** (includes `npm`) — to run the server - **Node.js 18+** (includes `npm`) — to run the server
- **curl** — to call the API - **curl** — to call the API
@@ -32,16 +32,19 @@ openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem openssl rsa -in private.pem -pubout -out public.pem
``` ```
Create your `.env` file: Copy the environment template and fill in your JWT keys:
```bash ```bash
cat > .env << 'EOF' cp .env.example .env
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp ```
REDIS_URL=redis://localhost:6379
PORT=3000 Write your JWT keys into `.env`:
JWT_PRIVATE_KEY="$(cat private.pem)"
JWT_PUBLIC_KEY="$(cat public.pem)" ```bash
EOF PRIVATE_KEY_LINE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)
PUBLIC_KEY_LINE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)
sed -i "s|JWT_PRIVATE_KEY=.*|JWT_PRIVATE_KEY=\"${PRIVATE_KEY_LINE}\"|" .env
sed -i "s|JWT_PUBLIC_KEY=.*|JWT_PUBLIC_KEY=\"${PUBLIC_KEY_LINE}\"|" .env
``` ```
> **Note**: The `.env` file stores your private key. Do not commit it to version control. > **Note**: The `.env` file stores your private key. Do not commit it to version control.
@@ -53,7 +56,7 @@ EOF
Start PostgreSQL and Redis using Docker Compose (infrastructure services only): Start PostgreSQL and Redis using Docker Compose (infrastructure services only):
```bash ```bash
docker-compose up -d postgres redis docker compose up -d postgres redis
``` ```
Expected output: Expected output:

View File

@@ -19,7 +19,7 @@ SentryAgent.ai AgentIdP is a Node.js REST API backed by PostgreSQL and Redis. It
| [Architecture](architecture.md) | All engineers | Components, ports, data flow, Redis key patterns | | [Architecture](architecture.md) | All engineers | Components, ports, data flow, Redis key patterns |
| [Environment Variables](environment-variables.md) | All engineers | Every env var — required, optional, format, examples | | [Environment Variables](environment-variables.md) | All engineers | Every env var — required, optional, format, examples |
| [Database](database.md) | Backend, DevOps | Schema (26 tables/migrations), how to apply and verify | | [Database](database.md) | Backend, DevOps | Schema (26 tables/migrations), how to apply and verify |
| [Local Development](local-development.md) | All engineers | docker-compose setup, startup, health checks | | [Local Development](local-development.md) | All engineers | Docker Compose setup (`compose.yaml`), startup, health checks |
| [Security](security.md) | All engineers | JWT key generation and rotation, CORS, secret storage | | [Security](security.md) | All engineers | JWT key generation and rotation, CORS, secret storage |
| [Operations](operations.md) | DevOps | Startup order, graceful shutdown, log interpretation, troubleshooting | | [Operations](operations.md) | DevOps | Startup order, graceful shutdown, log interpretation, troubleshooting |
| [field-trial.md](field-trial.md) | DevOps engineers, QA | In-house Docker Compose field trial execution playbook | | [field-trial.md](field-trial.md) | DevOps engineers, QA | In-house Docker Compose field trial execution playbook |

View File

@@ -6,6 +6,62 @@ Variables are loaded from a `.env` file at startup via `dotenv`. In production,
--- ---
## Docker Compose Variables
These variables are read by `compose.yaml` — not by the application itself. They are required when running the stack via `docker compose up`.
### `POSTGRES_USER`
PostgreSQL superuser name — used to configure the `postgres` container and construct `DATABASE_URL`.
| | |
|-|-|
| **Required for Compose** | Yes |
| **Default in `.env.example`** | `sentryagent` |
| **Example** | `POSTGRES_USER=sentryagent` |
---
### `POSTGRES_PASSWORD`
PostgreSQL superuser password.
| | |
|-|-|
| **Required for Compose** | Yes |
| **Default in `.env.example`** | `change-me-in-production` |
| **Example** | `POSTGRES_PASSWORD=strongpassword` |
> Never use the default value in production. Generate a strong random password.
---
### `POSTGRES_DB`
PostgreSQL database name to create on first startup.
| | |
|-|-|
| **Required for Compose** | Yes |
| **Default in `.env.example`** | `sentryagent_idp` |
| **Example** | `POSTGRES_DB=sentryagent_idp` |
---
### `GF_ADMIN_PASSWORD`
Grafana admin panel password — used by `compose.monitoring.yaml`.
| | |
|-|-|
| **Required for monitoring stack** | Yes |
| **Default in `.env.example`** | `change-me-in-production` |
| **Example** | `GF_ADMIN_PASSWORD=strongpassword` |
> Never use the default value in production.
---
## Required Variables ## Required Variables
These variables must be set. The server will throw and exit immediately if any are missing. These variables must be set. The server will throw and exit immediately if any are missing.
@@ -438,6 +494,12 @@ NODE_ENV=development
PORT=3000 PORT=3000
CORS_ORIGIN=http://localhost:3001 CORS_ORIGIN=http://localhost:3001
# ── Docker Compose (postgres container + monitoring) ─────────────────────────
POSTGRES_USER=sentryagent
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=sentryagent_idp
GF_ADMIN_PASSWORD=change-me-in-production
# ── Database ───────────────────────────────────────────────────────────────── # ── Database ─────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
DB_POOL_MAX=20 DB_POOL_MAX=20

View File

@@ -152,7 +152,10 @@ grep -E "^(DATABASE_URL|REDIS_URL|JWT_PRIVATE_KEY|JWT_PUBLIC_KEY|BILLING_ENABLED
Expected output (values abbreviated): Expected output (values abbreviated):
``` ```
DATABASE_URL=postgresql://agentidp:password@localhost:5432/agentidp POSTGRES_USER=sentryagent
POSTGRES_PASSWORD=sentryagent
POSTGRES_DB=sentryagent_idp
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n... JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n... JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...
@@ -187,8 +190,8 @@ Expected output — all three services must show `healthy`:
``` ```
NAME IMAGE STATUS NAME IMAGE STATUS
sentryagent-idp-app-1 sentryagent-idp-app running (healthy) sentryagent-idp-app-1 sentryagent-idp-app running (healthy)
sentryagent-idp-postgres-1 postgres:14-alpine running (healthy) sentryagent-idp-postgres-1 postgres:14.12-alpine3.19 running (healthy)
sentryagent-idp-redis-1 redis:7-alpine running (healthy) sentryagent-idp-redis-1 redis:7.2-alpine3.19 running (healthy)
``` ```
If any service shows `starting` or `unhealthy`, wait 15 seconds and run `docker compose ps` If any service shows `starting` or `unhealthy`, wait 15 seconds and run `docker compose ps`
@@ -787,7 +790,7 @@ Common causes:
| Service | Cause | Fix | | Service | Cause | Fix |
|---------|-------|-----| |---------|-------|-----|
| `postgres` | Wrong database credentials | Verify `DATABASE_URL` in `.env` matches `docker-compose.yml` credentials | | `postgres` | Wrong database credentials | Verify `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` in `.env` match values in `compose.yaml` |
| `redis` | Port conflict | Check `lsof -ti:6379` and kill occupying process | | `redis` | Port conflict | Check `lsof -ti:6379` and kill occupying process |
| `app` | Missing env var | Check `docker compose logs app` for `Failed to start server` message | | `app` | Missing env var | Check `docker compose logs app` for `Failed to start server` message |
@@ -825,7 +828,7 @@ Cause: A previous partial migration run left the database in an inconsistent sta
Fix: Check which migrations have been applied: Fix: Check which migrations have been applied:
```bash ```bash
docker compose exec postgres psql -U agentidp -d agentidp \ docker compose exec postgres psql -U sentryagent -d sentryagent_idp \
-c "SELECT name FROM schema_migrations ORDER BY name;" -c "SELECT name FROM schema_migrations ORDER BY name;"
``` ```

View File

@@ -17,7 +17,7 @@ Verify versions:
```bash ```bash
docker --version docker --version
docker-compose --version docker compose version
node --version node --version
npm --version npm --version
``` ```
@@ -57,18 +57,29 @@ Keep these files in the project root. They are used only locally and should not
## Step 3 — Configure environment ## Step 3 — Configure environment
Create a `.env` file in the project root: Copy the template and fill in your values:
```bash ```bash
cat > .env << 'ENVEOF' cp .env.example .env
```
The template already includes all required variables. At minimum, verify these are set correctly for local development:
```
POSTGRES_USER=sentryagent
POSTGRES_PASSWORD=sentryagent
POSTGRES_DB=sentryagent_idp
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
CORS_ORIGIN=* CORS_ORIGIN=*
ENVEOF
``` ```
> **Note:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` are used by `compose.yaml`
> to configure the PostgreSQL container and construct `DATABASE_URL`. They are not read by
> the application directly — only `DATABASE_URL` is.
Append the JWT keys to `.env`: Append the JWT keys to `.env`:
```bash ```bash
@@ -86,10 +97,10 @@ grep -E "^(DATABASE_URL|REDIS_URL|JWT_PRIVATE_KEY|JWT_PUBLIC_KEY)" .env
## Step 4 — Start infrastructure services ## Step 4 — Start infrastructure services
The `docker-compose.yml` defines three services: `postgres`, `redis`, and `app`. For local development, start only the infrastructure services — the application runs directly via Node.js. The `compose.yaml` defines three services: `postgres`, `redis`, and `app`. For local development, start only the infrastructure services — the application runs directly via Node.js.
```bash ```bash
docker-compose up -d postgres redis docker compose up -d postgres redis
``` ```
Expected output: Expected output:
@@ -100,7 +111,7 @@ Expected output:
✔ Container sentryagent-idp-redis-1 Healthy ✔ Container sentryagent-idp-redis-1 Healthy
``` ```
Both services must show `Healthy` before proceeding. If they show `Starting`, wait a few seconds and run `docker-compose ps` to recheck. Both services must show `Healthy` before proceeding. If they show `Starting`, wait a few seconds and run `docker compose ps` to recheck.
### Service ports ### Service ports
@@ -112,18 +123,18 @@ Both services must show `Healthy` before proceeding. If they show `Starting`, wa
Verify manually: Verify manually:
```bash ```bash
docker-compose exec postgres pg_isready -U sentryagent -d sentryagent_idp docker compose exec postgres pg_isready -U sentryagent -d sentryagent_idp
docker-compose exec redis redis-cli ping docker compose exec redis redis-cli ping
``` ```
### Docker volumes ### Docker volumes
Data is persisted in named Docker volumes: Data is persisted in named Docker volumes (kebab-case per Compose Spec standard):
| Volume | Service | Contents | | Volume | Service | Contents |
|--------|---------|---------| |--------|---------|---------|
| `sentryagent-idp_postgres_data` | PostgreSQL | All database data | | `sentryagent-idp_postgres-data` | PostgreSQL | All database data |
| `sentryagent-idp_redis_data` | Redis | Redis persistence (if enabled) | | `sentryagent-idp_redis-data` | Redis | Redis persistence (if enabled) |
--- ---
@@ -222,15 +233,13 @@ CORS_ORIGIN=http://localhost:3001
> deployments — see the [field trial guide](field-trial.md). For day-to-day development, start > deployments — see the [field trial guide](field-trial.md). For day-to-day development, start
> only the infrastructure services and run the application directly. > only the infrastructure services and run the application directly.
When the Dockerfile is available, the entire stack (infrastructure + application) can be started with: The entire stack (infrastructure + application) can be started with:
```bash ```bash
docker-compose up -d docker compose up --build -d
``` ```
The `app` service depends on `postgres` and `redis` with health check conditions, so it will not start until both services are healthy. The `app` service depends on `postgres` and `redis` with health check conditions, so it will not start until both services are healthy. Environment variables are loaded from `.env` via the `env_file` directive in `compose.yaml` (`required: false` — the file is optional if env vars are injected directly).
Environment variables for the container are loaded from `.env` via the `env_file` directive in `docker-compose.yml`.
--- ---
@@ -239,19 +248,19 @@ Environment variables for the container are loaded from `.env` via the `env_file
Stop infrastructure only (preserves volumes): Stop infrastructure only (preserves volumes):
```bash ```bash
docker-compose stop postgres redis docker compose stop postgres redis
``` ```
Stop and remove containers (preserves volumes): Stop and remove containers (preserves volumes):
```bash ```bash
docker-compose down docker compose down
``` ```
Stop and remove containers AND volumes (destroys all data): Stop and remove containers AND volumes (destroys all data):
```bash ```bash
docker-compose down -v docker compose down -v
``` ```
> Use `-v` only when you want a clean slate. This deletes all PostgreSQL data and Redis data permanently. > Use `-v` only when you want a clean slate. This deletes all PostgreSQL data and Redis data permanently.

View File

@@ -111,7 +111,7 @@ Three key patterns are used in Redis. Useful for debugging and manual inspection
```bash ```bash
# Connect to Redis CLI # Connect to Redis CLI
docker-compose exec redis redis-cli docker compose exec redis redis-cli
``` ```
| Key pattern | Example | Purpose | TTL | | Key pattern | Example | Purpose | TTL |
@@ -192,10 +192,10 @@ Error: connect ECONNREFUSED 127.0.0.1:5432
| Cause | Fix | | Cause | Fix |
|-------|-----| |-------|-----|
| PostgreSQL container not started | Run `docker-compose up -d postgres` | | PostgreSQL container not started | Run `docker compose up -d postgres` |
| PostgreSQL container not yet healthy | Wait and run `docker-compose ps` — wait for `healthy` | | PostgreSQL container not yet healthy | Wait and run `docker compose ps` — wait for `healthy` |
| Wrong `DATABASE_URL` host/port | Check `DATABASE_URL` matches the PostgreSQL port (5432) | | Wrong `DATABASE_URL` host/port | Check `DATABASE_URL` matches the PostgreSQL port (5432) |
| PostgreSQL container exited | Run `docker-compose logs postgres` to see why it exited | | PostgreSQL container exited | Run `docker compose logs postgres` to see why it exited |
--- ---
@@ -210,8 +210,8 @@ Redis client error Error: connect ECONNREFUSED 127.0.0.1:6379
| Cause | Fix | | Cause | Fix |
|-------|-----| |-------|-----|
| Redis container not started | Run `docker-compose up -d redis` | | Redis container not started | Run `docker compose up -d redis` |
| Redis container not yet healthy | Run `docker-compose ps` — wait for `healthy` | | Redis container not yet healthy | Run `docker compose ps` — wait for `healthy` |
| Wrong `REDIS_URL` | Check `REDIS_URL` matches the Redis port (6379) | | Wrong `REDIS_URL` | Check `REDIS_URL` matches the Redis port (6379) |
--- ---
@@ -257,7 +257,7 @@ If a migration is listed there but the table is inconsistent, manually inspect a
# Find the current window key # Find the current window key
WINDOW=$(node -e "console.log(Math.floor(Date.now() / 60000))") WINDOW=$(node -e "console.log(Math.floor(Date.now() / 60000))")
# Check count for a specific client # Check count for a specific client
docker-compose exec redis redis-cli GET "rate:<client_id>:$WINDOW" 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. **Fix:** Wait until `X-RateLimit-Reset` (Unix timestamp in the response header) before retrying. The window resets every 60 seconds.
@@ -296,10 +296,10 @@ AgentIdP exposes a Prometheus metrics endpoint at `GET /metrics` (unauthenticate
```bash ```bash
# Start the full stack with monitoring # Start the full stack with monitoring
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d docker compose -f compose.yaml -f compose.monitoring.yaml up -d
# Prometheus: http://localhost:9090 # Prometheus: http://localhost:9090
# Grafana: http://localhost:3001 (admin / agentidp) # Grafana: http://localhost:3001 (admin / <GF_ADMIN_PASSWORD from .env>)
``` ```
The Grafana dashboard auto-provisions on first start. Navigate to **Dashboards → AgentIdP → SentryAgent.ai — AgentIdP**. The Grafana dashboard auto-provisions on first start. Navigate to **Dashboards → AgentIdP → SentryAgent.ai — AgentIdP**.

View File

@@ -123,8 +123,8 @@ rate-limiter uses a Redis sorted set for the sliding-window algorithm.
- PostgreSQL for revocation — rejected because the token verification path is the hot path in every authenticated request. A PostgreSQL round-trip adds 515 ms compared to a Redis `GET` at sub-millisecond latency. - PostgreSQL for revocation — rejected because the token verification path is the hot path in every authenticated request. A PostgreSQL round-trip adds 515 ms compared to a Redis `GET` at sub-millisecond latency.
**Consequences**: Redis is a required infrastructure dependency. A Redis instance must **Consequences**: Redis is a required infrastructure dependency. A Redis instance must
be running and reachable via `REDIS_URL` before the server starts. `docker-compose.yml` be running and reachable via `REDIS_URL` before the server starts. `compose.yaml`
provides a Redis 7 Alpine container for local development on port 6379. provides a Redis 7.2 Alpine container for local development on port 6379.
--- ---
@@ -217,7 +217,7 @@ environments. The `prom-client` npm package integrates natively with Express and
provides `Counter` and `Histogram` metric types that cover all observability needs for provides `Counter` and `Histogram` metric types that cover all observability needs for
AgentIdP. Grafana's YAML provisioning in `monitoring/grafana/provisioning/` makes AgentIdP. Grafana's YAML provisioning in `monitoring/grafana/provisioning/` makes
dashboards reproducible and version-controlled. The monitoring stack runs as a Docker dashboards reproducible and version-controlled. The monitoring stack runs as a Docker
Compose overlay (`docker-compose.monitoring.yml`) without interfering with the base dev Compose overlay (`compose.monitoring.yaml`) without interfering with the base dev
environment. environment.
**Alternatives considered**: **Alternatives considered**:

View File

@@ -56,8 +56,8 @@ sentryagent-idp/
│ ├── agntcy-conformance/ # AGNTCY conformance test suite (separate Jest config) │ ├── agntcy-conformance/ # AGNTCY conformance test suite (separate Jest config)
│ └── load/ # k6 load test scripts │ └── load/ # k6 load test scripts
├── Dockerfile # Multi-stage production build (build + runtime stages) ├── Dockerfile # Multi-stage production build (build + runtime stages)
├── docker-compose.yml # Local development: PostgreSQL 14 (port 5432) + Redis 7 (port 6379) ├── compose.yaml # Local development: PostgreSQL 14.12 (port 5432) + Redis 7.2 (port 6379)
├── docker-compose.monitoring.yml # Monitoring overlay: Prometheus (port 9090) + Grafana (port 3001) ├── compose.monitoring.yaml # Monitoring overlay: Prometheus (port 9090) + Grafana (port 3001)
├── package.json # Node.js dependencies and npm scripts ├── package.json # Node.js dependencies and npm scripts
├── tsconfig.json # TypeScript strict configuration — compiled to dist/ ├── tsconfig.json # TypeScript strict configuration — compiled to dist/
└── jest.config.ts # Jest configuration — ts-jest, test timeouts, coverage thresholds └── jest.config.ts # Jest configuration — ts-jest, test timeouts, coverage thresholds
@@ -134,11 +134,14 @@ The `errorHandler` middleware in `src/middleware/errorHandler.ts` maps
`SentryAgentError` subclasses to their `httpStatus` codes and serialises the response `SentryAgentError` subclasses to their `httpStatus` codes and serialises the response
as `IErrorResponse { code, message, details }`. as `IErrorResponse { code, message, details }`.
**`docker-compose.yml`** **`compose.yaml`**
Starts PostgreSQL 14 (Alpine) on port 5432 with database `sentryagent_idp` and Starts PostgreSQL 14.12 (Alpine) on port 5432 and Redis 7.2 (Alpine) on port 6379.
Redis 7 (Alpine) on port 6379. Used for local development only. Both services have All services use a dedicated `app-tier` bridge network, `restart: unless-stopped`,
health checks so `depends_on` conditions work correctly. The `app` service mounts and `deploy.resources.limits` per DockerSpec standards. Both infrastructure services
`./src` as a read-only volume for live code reloading. have health checks so `depends_on` conditions work correctly. The `app` service mounts
`./src` as a read-only bind volume for live code reloading and has its own
`healthcheck` probe via `curl /health`. Postgres credentials and Grafana admin
password are externalized to environment variables — see `docs/devops/environment-variables.md`.
**`tsconfig.json`** **`tsconfig.json`**
TypeScript compiler configuration. `strict: true` enables the full suite of strictness TypeScript compiler configuration. `strict: true` enables the full suite of strictness

View File

@@ -332,10 +332,10 @@ not exposed to the public internet.
Start the monitoring overlay: Start the monitoring overlay:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up docker compose -f compose.yaml -f compose.monitoring.yaml up
``` ```
- Prometheus: `http://localhost:9090` - Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3001` — default credentials: `admin` / `agentidp` - Grafana: `http://localhost:3001` — credentials: `admin` / `<GF_ADMIN_PASSWORD from .env>`
Grafana is pre-provisioned with a Prometheus data source pointing to `http://prometheus:9090` Grafana is pre-provisioned with a Prometheus data source pointing to `http://prometheus:9090`
and dashboard JSON files from `monitoring/grafana/dashboards/`. No manual configuration and dashboard JSON files from `monitoring/grafana/dashboards/`. No manual configuration

View File

@@ -44,18 +44,24 @@ development dependencies (TypeScript, Jest, ts-jest, eslint).
## 8.3 Environment Variables Setup ## 8.3 Environment Variables Setup
The server requires a `.env` file at the project root. There is no `.env.example` The server requires a `.env` file at the project root. Copy the template:
file — create it from scratch using the template below.
```bash ```bash
touch .env cp .env.example .env
``` ```
Add the following content to `.env`. Every variable is documented below. The template includes all required variables with sensible local defaults. Edit `.env` to set your values. Key variables are documented below.
```bash ```bash
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# PostgreSQL connection # PostgreSQL — individual credentials for compose.yaml
# ─────────────────────────────────────────────────────────────
POSTGRES_USER=sentryagent
POSTGRES_PASSWORD=sentryagent
POSTGRES_DB=sentryagent_idp
# ─────────────────────────────────────────────────────────────
# PostgreSQL connection (application reads this directly)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp

View File

@@ -8,12 +8,12 @@ This document covers building and running AgentIdP in production: Docker, enviro
The Dockerfile uses a two-stage build: The Dockerfile uses a two-stage build:
- **Stage 1 (builder):** `node:18-alpine` — installs all dependencies (including dev) and compiles TypeScript to `dist/`. - **Stage 1 (build):** `node:20.11-bookworm-slim` — installs all dependencies (including dev) and compiles TypeScript to `dist/`.
- **Stage 2 (production):** `node:18-alpine` — copies `dist/` and `node_modules` (production only), runs as the built-in non-root `node` user. - **Stage 2 (final):** `node:20.11-bookworm-slim` — copies `dist/` and `node_modules` (production only), installs `curl` for healthcheck, and runs as the created non-root `nodeapp` user (UID 1001).
```bash ```bash
# Build # Build
docker build -t sentryagent-idp:latest . docker build -t sentryagent-idp:1.0.0 .
# Run (supply required env vars) # Run (supply required env vars)
docker run -d \ docker run -d \
@@ -22,18 +22,18 @@ docker run -d \
-e REDIS_URL=redis://<host>:6379 \ -e REDIS_URL=redis://<host>:6379 \
-e JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..." \ -e JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..." \
-e JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..." \ -e JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..." \
sentryagent-idp:latest sentryagent-idp:1.0.0
``` ```
The container exposes port `3000`. Override with `PORT` environment variable if needed. The container exposes port `3000`. Override with `PORT` environment variable if needed. The container runs as non-root user `nodeapp` (UID 1001) — do not mount volumes requiring root ownership.
For local full-stack development, use Docker Compose instead: For local full-stack development, use Docker Compose instead:
```bash ```bash
docker compose up -d docker compose up --build -d
``` ```
The `docker-compose.yml` starts the app, PostgreSQL 14, and Redis 7 with health checks and data volumes. The `compose.yaml` starts the app, PostgreSQL 14.12, and Redis 7.2 with health checks, resource limits, restart policies, and data volumes — per DockerSpec standards.
--- ---
@@ -178,11 +178,11 @@ The HTTP metrics (`agentidp_http_requests_total` and `agentidp_http_request_dura
### Local Grafana ### Local Grafana
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d docker compose -f compose.yaml -f compose.monitoring.yaml up -d
``` ```
- Prometheus: http://localhost:9090 - Prometheus: http://localhost:9090
- Grafana: http://localhost:3001 (admin password: `agentidp`) - Grafana: http://localhost:3001 (admin password: `GF_ADMIN_PASSWORD` value from `.env`)
The monitoring compose overlay starts `prom/prometheus:v2.53.0` and `grafana/grafana:11.2.0`. Grafana dashboards and datasource provisioning are loaded from `monitoring/grafana/provisioning/`. The monitoring compose overlay starts `prom/prometheus:v2.53.0` and `grafana/grafana:11.2.0`. Grafana dashboards and datasource provisioning are loaded from `monitoring/grafana/provisioning/`.

View File

@@ -13,6 +13,12 @@ info:
and lifecycle status management. The registry is the authoritative source of and lifecycle status management. The registry is the authoritative source of
truth for all registered agent identities. truth for all registered agent identities.
**Tenant Isolation**:
All agent endpoints enforce strict organization-level tenant isolation. The
caller's `organization_id` is derived exclusively from the verified JWT
`organization_id` claim — it can never be overridden by request body values
or query parameters. Cross-tenant access always returns `403 Forbidden`.
**Free Tier Limits**: **Free Tier Limits**:
- Max 100 registered agents per account - Max 100 registered agents per account
- API rate limit: 100 requests/minute - API rate limit: 100 requests/minute
@@ -38,6 +44,10 @@ components:
(`POST /token`). Include in the `Authorization` header as: (`POST /token`). Include in the `Authorization` header as:
`Authorization: Bearer <token>` `Authorization: Bearer <token>`
The JWT must contain an `organization_id` claim. This claim is used
to scope all agent operations to the caller's organization and cannot
be overridden by any value in the request body or query string.
schemas: schemas:
AgentType: AgentType:
type: string type: string
@@ -294,14 +304,14 @@ components:
message: "A valid Bearer token is required to access this resource." message: "A valid Bearer token is required to access this resource."
Forbidden: Forbidden:
description: Valid token but insufficient permissions. description: The caller does not have permission to access this resource.
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
example: example:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to perform this action." message: "You do not have permission to access this resource."
NotFound: NotFound:
description: The requested resource was not found. description: The requested resource was not found.
@@ -365,6 +375,12 @@ paths:
A unique immutable `agentId` (UUID) is system-assigned on creation. A unique immutable `agentId` (UUID) is system-assigned on creation.
The `email` must be unique across all registered agents. The `email` must be unique across all registered agents.
**Tenant Isolation — Rule 3 (Register Scoping)**:
The agent is always registered under the caller's organization, derived
from the JWT `organization_id` claim. Any `organizationId` value provided
in the request body is silently ignored. It is not possible to register
an agent under a different organization, regardless of request body content.
**Free Tier**: Maximum 100 registered agents per account. Attempting to **Free Tier**: Maximum 100 registered agents per account. Attempting to
register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`. register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`.
requestBody: requestBody:
@@ -430,17 +446,23 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
description: Forbidden. Either insufficient permissions or free tier limit reached. description: |
Forbidden. One of the following conditions applies:
- **`AUTHORIZATION_ERROR`**: The caller's JWT does not grant permission to
register agents in their organization.
- **`FREE_TIER_LIMIT_EXCEEDED`**: The free tier limit of 100 registered
agents per account has been reached.
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
examples: examples:
insufficientPermissions: authorizationError:
summary: Insufficient permissions summary: Caller does not have permission to register agents
value: value:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to register agents." message: "You do not have permission to access this resource."
freeTierLimit: freeTierLimit:
summary: Free tier agent limit reached summary: Free tier agent limit reached
value: value:
@@ -471,10 +493,16 @@ paths:
- Agent Registry - Agent Registry
summary: List registered agents summary: List registered agents
description: | description: |
Returns a paginated list of all registered AI agent identities accessible Returns a paginated list of registered AI agent identities belonging to
to the authenticated caller. the caller's organization.
**Tenant Isolation — Rule 1 (List Scoping)**:
Results are always scoped to the caller's organization, derived from the
JWT `organization_id` claim. It is not possible to retrieve agents from
another organization. The `owner` query parameter sub-filters within the
caller's organization only — it does not widen the scope beyond the
caller's organization.
Results can be filtered by `owner`, `agentType`, and/or `status`.
Results are ordered by `createdAt` descending (most recent first). Results are ordered by `createdAt` descending (most recent first).
parameters: parameters:
- name: page - name: page
@@ -498,7 +526,9 @@ paths:
example: 20 example: 20
- name: owner - name: owner
in: query in: query
description: Filter agents by owner name (exact match). description: |
Filter agents by owner name (exact match). Applies within the caller's
organization only — does not allow cross-tenant access.
required: false required: false
schema: schema:
type: string type: string
@@ -580,7 +610,16 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
$ref: '#/components/responses/Forbidden' description: |
Forbidden. The caller's JWT does not grant permission to list agents
in their organization.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'429': '429':
$ref: '#/components/responses/TooManyRequests' $ref: '#/components/responses/TooManyRequests'
'500': '500':
@@ -604,6 +643,13 @@ paths:
summary: Get agent by ID summary: Get agent by ID
description: | description: |
Retrieves the full identity record for a single AI agent by its immutable `agentId`. Retrieves the full identity record for a single AI agent by its immutable `agentId`.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
This applies regardless of whether the `agentId` exists. A caller from
Org A cannot determine the existence of an agent belonging to Org B.
responses: responses:
'200': '200':
description: Agent record returned successfully. description: Agent record returned successfully.
@@ -641,7 +687,17 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
$ref: '#/components/responses/Forbidden' description: |
Forbidden. The target agent belongs to a different organization than
the caller's. The caller's `organization_id` (from JWT) does not match
the `organization_id` stored on the target agent record.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'429': '429':
@@ -663,6 +719,12 @@ paths:
Setting `status` to `decommissioned` is a one-way operation — a Setting `status` to `decommissioned` is a one-way operation — a
decommissioned agent cannot be reactivated. decommissioned agent cannot be reactivated.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
It is not possible to update an agent belonging to a different organization.
requestBody: requestBody:
required: true required: true
content: content:
@@ -737,17 +799,24 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
description: Forbidden. Insufficient permissions or agent is decommissioned. description: |
Forbidden. One of the following conditions applies:
- **`AUTHORIZATION_ERROR`**: The target agent belongs to a different
organization than the caller's. The caller's `organization_id` (from JWT)
does not match the `organization_id` stored on the target agent record.
- **`AGENT_DECOMMISSIONED`**: The target agent has been permanently
decommissioned and cannot be updated.
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
examples: examples:
forbidden: authorizationError:
summary: Insufficient permissions summary: Cross-tenant access denied
value: value:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to update this agent." message: "You do not have permission to access this resource."
decommissioned: decommissioned:
summary: Agent is decommissioned summary: Agent is decommissioned
value: value:
@@ -777,6 +846,12 @@ paths:
- The agent can no longer authenticate or obtain tokens. - The agent can no longer authenticate or obtain tokens.
- The agent record remains visible in the registry with status `decommissioned`. - The agent record remains visible in the registry with status `decommissioned`.
- This operation is **irreversible**. - This operation is **irreversible**.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
It is not possible to decommission an agent belonging to a different organization.
responses: responses:
'204': '204':
description: Agent decommissioned successfully. No response body. description: Agent decommissioned successfully. No response body.
@@ -796,7 +871,17 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '403':
$ref: '#/components/responses/Forbidden' description: |
Forbidden. The target agent belongs to a different organization than
the caller's. The caller's `organization_id` (from JWT) does not match
the `organization_id` stored on the target agent record.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'404': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409': '409':

View File

@@ -0,0 +1,5 @@
id: tenant-isolation-enforcement
title: Enforce tenant isolation on all agent endpoints
status: active
type: security
created: 2026-04-08

View File

@@ -0,0 +1,64 @@
# Technical Design: Tenant Isolation Enforcement
## Overview
Tenant isolation is enforced by threading the caller's `organization_id` (extracted from the verified JWT) through the controller → service → repository call chain. No caller-supplied body value or query parameter may override this. The JWT is the sole authoritative source of organization context.
## JWT Claim Source
`ITokenPayload` already carries `organization_id: string`. The Express middleware that verifies the JWT attaches the decoded payload to `req.user`. Controllers read `req.user.organization_id` and pass it down the stack.
## Enforcement Points
### Rule 1 — List Scoping (`GET /agents`)
**Where:** `AgentController.listAgents()``AgentService.listAgents()``AgentRepository.findAll()`
**Mechanism:**
1. `AgentController.listAgents()` sets `filters.organizationId = req.user.organization_id` unconditionally, overwriting any value that might have arrived in the query string.
2. `AgentRepository.findAll()` always includes `WHERE organization_id = $n` when `organizationId` is present in `IAgentListFilters`. Because the controller always sets it, this clause is always active.
3. The `owner` query parameter is applied as an additional `AND owner = $n` clause — it sub-filters within the org, never across orgs.
**Result:** A caller from Org A cannot receive any agent record belonging to Org B, regardless of query parameters supplied.
### Rule 2 — Ownership Guard (`GET`, `PATCH`, `DELETE` on `/agents/{agentId}`)
**Where:** `AgentService.getAgentById()`, `AgentService.updateAgent()`, `AgentService.decommissionAgent()`
**Mechanism:**
1. The repository fetches the agent record by `agentId` without org filtering (the ID lookup is always exact-match by primary key).
2. Immediately after retrieval, the service compares `agent.organizationId` against the `callerOrganizationId` parameter passed in from the controller.
3. If they do not match, the service throws `AuthorizationError` with code `AUTHORIZATION_ERROR` and message "You do not have permission to access this resource."
4. The controller's error handler maps `AuthorizationError` → HTTP 403.
**Invariant:** An agent record is returned (or mutated/deleted) only if the caller's JWT org matches the stored org on that record. A non-matching ID returns 403, not 404 — this prevents org enumeration via timing differences.
### Rule 3 — Register Scoping (`POST /agents`)
**Where:** `AgentController.registerAgent()`
**Mechanism:**
1. The controller ignores any `organizationId` field in `req.body`.
2. Before calling the service, it sets `organizationId = req.user.organization_id`.
3. The service and repository receive only the JWT-derived value.
**Result:** It is impossible for a caller to register an agent under a foreign org, regardless of request body content.
## Error Type
A new (or existing) `AuthorizationError` class in the `SentryAgentError` hierarchy is used. It carries:
- `code: "AUTHORIZATION_ERROR"`
- HTTP status: `403`
- `message: "You do not have permission to access this resource."`
This is distinct from the existing `ForbiddenError` (which covers role/permission checks) to allow fine-grained programmatic handling by API consumers.
## Database Considerations
No schema changes are required. The `agents` table already stores `organization_id`. The enforcement is purely at the application layer. Existing indexes on `organization_id` ensure the scoped list query remains performant.
## Security Properties
- **No information leakage:** Cross-tenant requests return 403, not 404. This means a caller from Org A cannot determine whether an agent with a given ID exists in Org B.
- **No parameter injection:** `organizationId` is never read from the request body or query string for scoping purposes — only from the verified JWT.
- **Defense in depth:** Enforcement is at the service layer, not just the controller, ensuring the invariant holds even if the service is called from other internal paths.

View File

@@ -0,0 +1,54 @@
# Proposal: Enforce Tenant Isolation on All Agent Endpoints
## Title
Enforce tenant (organization) isolation on all agent CRUD endpoints — P0 Security Fix
## Problem Statement
Field trial Test C.7 — Org Isolation Failure — has identified a critical security defect.
All five agent endpoints (`POST /agents`, `GET /agents`, `GET /agents/{agentId}`,
`PATCH /agents/{agentId}`, `DELETE /agents/{agentId}`) perform **no tenant isolation**.
Any authenticated agent from Organization A can:
- Read the full agent list of Organization B (`GET /agents`)
- Read any individual agent record across any organization (`GET /agents/{agentId}`)
- Modify any agent's metadata across any organization (`PATCH /agents/{agentId}`)
- Decommission any agent across any organization (`DELETE /agents/{agentId}`)
- Register agents under any organization by supplying an arbitrary `organizationId` in the request body (`POST /agents`)
The JWT issued by the system already contains an `organization_id` claim (present in `ITokenPayload`). The enforcement layer between this claim and the data access layer is entirely absent.
This is a **P0 security incident** — it breaks multi-tenancy at its most fundamental level and must be resolved before any field trial continues.
## Proposed Solution
Enforce organization scoping at the service layer, driven by the `organization_id` claim extracted from the verified JWT on every request. No request body value or query parameter may override the caller's organization context.
Three enforcement rules are applied:
**Rule 1 — List scoping (`GET /agents`):** Results are always filtered to the caller's `organization_id`. The `owner` query parameter may further sub-filter within the caller's org, but can never widen the scope beyond it.
**Rule 2 — Ownership guard (`GET /agents/{agentId}`, `PATCH /agents/{agentId}`, `DELETE /agents/{agentId}`):** After retrieving the target agent record, the service compares the agent's stored `organization_id` against the caller's `organization_id`. If they do not match, the operation is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
**Rule 3 — Register scoping (`POST /agents`):** The `organizationId` field in the request body is ignored. The agent is always registered under the caller's `organization_id` from the JWT, regardless of what the body contains.
## Scope of Changes
- `src/types/index.ts` — add `organizationId` field to `IAgentListFilters`
- `src/repositories/AgentRepository.ts` — filter `findAll()` by `organizationId`
- `src/services/AgentService.ts` — pass `organizationId` into `getAgentById()`, `updateAgent()`, `decommissionAgent()`; throw `AuthorizationError` on mismatch
- `src/controllers/AgentController.ts` — extract `req.user.organization_id` and apply to all five endpoint handlers
- `docs/openapi/agent-registry.yaml` — document enforcement rules and 403 responses on all five endpoints
- `src/tests/` — add Test C.7 regression suite and ownership guard tests
## Acceptance Criteria
- [ ] `GET /agents` never returns agents from a different organization than the caller's
- [ ] `GET /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
- [ ] `PATCH /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
- [ ] `DELETE /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
- [ ] `POST /agents` ignores any `organizationId` in the request body; agent is always registered under the caller's org
- [ ] OpenAPI spec documents these rules and all 403 responses on all five endpoints
- [ ] Test C.7 regression suite passes
- [ ] All ownership guard paths have test coverage
- [ ] Overall test coverage remains above 80%

View File

@@ -0,0 +1,10 @@
# Implementation Tasks: Tenant Isolation Enforcement
- [x] Add `organizationId` field to `IAgentListFilters` in `src/types/index.ts`
- [x] Update `AgentRepository.findAll()` to filter by `organizationId`
- [x] Add `organizationId` parameter to `AgentService.getAgentById()`, `updateAgent()`, `decommissionAgent()`; throw `AuthorizationError` on mismatch
- [x] Update `AgentController.registerAgent()` to force `organizationId` from `req.user.organization_id`
- [x] Update `AgentController.listAgents()` to force `filters.organizationId` from `req.user.organization_id`
- [x] Update `AgentController.getAgentById()`, `updateAgent()`, `decommissionAgent()` to pass `req.user.organization_id` to service
- [x] Update `docs/openapi/agent-registry.yaml` with 403 responses and security enforcement descriptions
- [x] Ownership guard unit tests added to `tests/unit/controllers/AgentController.test.ts` (23 tests, all passing). Note: Test C.7 end-to-end regression is a field trial integration test run by DevOps against live containers — it is not a unit test.

6
package-lock.json generated
View File

@@ -6202,9 +6202,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {

View File

@@ -41,6 +41,7 @@ if [ ! -f "$CTO_WORKSPACE/CLAUDE.md" ]; then
exit 1 exit 1
fi fi
# Launch Claude Code in the CTO workspace # Launch Claude Code in the CTO workspace with full autonomy
# --dangerously-skip-permissions bypasses all approval prompts — no Shift+Tab needed
cd "$CTO_WORKSPACE" cd "$CTO_WORKSPACE"
exec claude exec claude --dangerously-skip-permissions

52
scripts/start-tbc.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# =============================================================================
# SentryAgent.ai — Start Technical & Business Consultant (TBC)
# =============================================================================
# Launches a separate Claude Code instance as the TBC.
# The TBC is an independent advisory function reporting directly to the CEO.
# It does NOT interact with the CTO or engineering team.
#
# Usage:
# ./scripts/start-tbc.sh
#
# The TBC agent runs in its own terminal session and communicates
# with the CEO via the central hub (#tbc-ceo channel).
# =============================================================================
set -e
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TBC_WORKSPACE="$PROJECT_ROOT/.tbc-workspace"
echo "=============================================="
echo " SentryAgent.ai — Starting TBC Agent"
echo " (Technical & Business Consultant)"
echo "=============================================="
echo ""
echo " Project: $PROJECT_ROOT"
echo " Workspace: $TBC_WORKSPACE"
echo " Hub Channel: #tbc-ceo"
echo ""
echo " The TBC will:"
echo " 1. Read PRD.md, README.md, and TBC/charter.md"
echo " 2. Register on central hub as TBC"
echo " 3. Check #tbc-ceo for pending CEO messages"
echo " 4. Report session-open status to CEO"
echo " 5. Await CEO agenda"
echo ""
echo " Note: TBC is advisory only."
echo " It does NOT interact with the CTO or engineering team."
echo ""
echo "=============================================="
echo ""
# Verify the TBC workspace exists
if [ ! -f "$TBC_WORKSPACE/CLAUDE.md" ]; then
echo "ERROR: TBC workspace not found at $TBC_WORKSPACE/CLAUDE.md"
echo "Please ensure the project is set up correctly."
exit 1
fi
# Launch Claude Code in the TBC workspace
cd "$TBC_WORKSPACE"
exec claude

View File

@@ -48,7 +48,14 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const data = value as ICreateAgentRequest; const data = value as ICreateAgentRequest;
// Rule 3: always register under the caller's org — body value is ignored.
data.organizationId = organizationId;
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
@@ -80,8 +87,15 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAgentListFilters = { const filters: IAgentListFilters = {
// organizationId is forced from JWT — never from query params.
organizationId,
page: value.page as number, page: value.page as number,
limit: value.limit as number, limit: value.limit as number,
owner: value.owner as string | undefined, owner: value.owner as string | undefined,
@@ -110,8 +124,13 @@ export class AgentController {
throw new AuthorizationError(); throw new AuthorizationError();
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
const agent = await this.agentService.getAgentById(agentId); const agent = await this.agentService.getAgentById(agentId, organizationId);
res.status(200).json(agent); res.status(200).json(agent);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -148,6 +167,11 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
const data: IUpdateAgentRequest = { const data: IUpdateAgentRequest = {
@@ -163,7 +187,7 @@ export class AgentController {
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent); const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent, organizationId);
res.status(200).json(updated); res.status(200).json(updated);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -183,11 +207,16 @@ export class AgentController {
throw new AuthorizationError(); throw new AuthorizationError();
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.agentService.decommissionAgent(agentId, ipAddress, userAgent); await this.agentService.decommissionAgent(agentId, ipAddress, userAgent, organizationId);
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
next(err); next(err);

View File

@@ -129,8 +129,10 @@ export class AgentRepository {
/** /**
* Returns a paginated list of agents with optional filters. * Returns a paginated list of agents with optional filters.
* When `organizationId` is provided the result set is strictly scoped to that
* organization — agents belonging to other organizations are never returned.
* *
* @param filters - Pagination and filter criteria. * @param filters - Pagination and filter criteria (organizationId is applied first).
* @returns Object containing the agent list and total count. * @returns Object containing the agent list and total count.
*/ */
async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> { async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> {
@@ -138,6 +140,11 @@ export class AgentRepository {
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
if (filters.organizationId !== undefined) {
conditions.push(`organization_id = $${paramIndex++}`);
params.push(filters.organizationId);
}
if (filters.owner !== undefined) { if (filters.owner !== undefined) {
conditions.push(`owner = $${paramIndex++}`); conditions.push(`owner = $${paramIndex++}`);
params.push(filters.owner); params.push(filters.owner);

View File

@@ -21,6 +21,7 @@ import {
AgentAlreadyExistsError, AgentAlreadyExistsError,
AgentAlreadyDecommissionedError, AgentAlreadyDecommissionedError,
FreeTierLimitError, FreeTierLimitError,
AuthorizationError,
} from '../utils/errors.js'; } from '../utils/errors.js';
import { agentsRegisteredTotal } from '../metrics/registry.js'; import { agentsRegisteredTotal } from '../metrics/registry.js';
import { TierService } from './TierService.js'; import { TierService } from './TierService.js';
@@ -140,16 +141,23 @@ export class AgentService {
/** /**
* Retrieves a single agent by its UUID. * Retrieves a single agent by its UUID.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID. * @param agentId - The agent UUID.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @returns The agent record. * @returns The agent record.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AuthorizationError if the agent belongs to a different organization.
*/ */
async getAgentById(agentId: string): Promise<IAgent> { async getAgentById(agentId: string, organizationId?: string): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
return agent; return agent;
} }
@@ -173,14 +181,18 @@ export class AgentService {
* Partially updates an agent's metadata. * Partially updates an agent's metadata.
* Immutable fields (agentId, email, createdAt) cannot be changed. * Immutable fields (agentId, email, createdAt) cannot be changed.
* Decommissioned agents cannot be updated. * Decommissioned agents cannot be updated.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID to update. * @param agentId - The agent UUID to update.
* @param data - The fields to update. * @param data - The fields to update.
* @param ipAddress - Client IP for audit logging. * @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging. * @param userAgent - Client User-Agent for audit logging.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @returns The updated agent record. * @returns The updated agent record.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned. * @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
* @throws AuthorizationError if the agent belongs to a different organization.
* @throws ValidationError if immutable fields are included. * @throws ValidationError if immutable fields are included.
*/ */
async updateAgent( async updateAgent(
@@ -188,12 +200,17 @@ export class AgentService {
data: IUpdateAgentRequest, data: IUpdateAgentRequest,
ipAddress: string, ipAddress: string,
userAgent: string, userAgent: string,
organizationId?: string,
): Promise<IAgent> { ): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
if (agent.status === 'decommissioned') { if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId); throw new AgentAlreadyDecommissionedError(agentId);
} }
@@ -256,23 +273,32 @@ export class AgentService {
/** /**
* Permanently decommissions an agent (soft delete). * Permanently decommissions an agent (soft delete).
* Revokes all active credentials for the agent. * Revokes all active credentials for the agent.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID to decommission. * @param agentId - The agent UUID to decommission.
* @param ipAddress - Client IP for audit logging. * @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging. * @param userAgent - Client User-Agent for audit logging.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if already decommissioned. * @throws AgentAlreadyDecommissionedError if already decommissioned.
* @throws AuthorizationError if the agent belongs to a different organization.
*/ */
async decommissionAgent( async decommissionAgent(
agentId: string, agentId: string,
ipAddress: string, ipAddress: string,
userAgent: string, userAgent: string,
organizationId?: string,
): Promise<void> { ): Promise<void> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
if (agent.status === 'decommissioned') { if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId); throw new AgentAlreadyDecommissionedError(agentId);
} }

View File

@@ -170,6 +170,8 @@ export interface IPaginatedAgentsResponse {
/** Query filters for listing agents. */ /** Query filters for listing agents. */
export interface IAgentListFilters { export interface IAgentListFilters {
/** Restricts results to agents belonging to this organization. Enforced by the controller from the JWT claim. */
organizationId?: string;
owner?: string; owner?: string;
agentType?: AgentType; agentType?: AgentType;
status?: AgentStatus; status?: AgentStatus;

View File

@@ -15,6 +15,8 @@ const MockAgentService = AgentService as jest.MockedClass<typeof AgentService>;
// ─── helpers ───────────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────────
const MOCK_ORG_ID = 'org-test-001';
const MOCK_USER: ITokenPayload = { const MOCK_USER: ITokenPayload = {
sub: 'agent-id-001', sub: 'agent-id-001',
client_id: 'agent-id-001', client_id: 'agent-id-001',
@@ -22,11 +24,12 @@ const MOCK_USER: ITokenPayload = {
jti: 'jti-001', jti: 'jti-001',
iat: 1000, iat: 1000,
exp: 9999999999, exp: 9999999999,
organization_id: MOCK_ORG_ID,
}; };
const MOCK_AGENT: IAgent = { const MOCK_AGENT: IAgent = {
agentId: 'agent-id-001', agentId: 'agent-id-001',
organizationId: 'org_system', organizationId: MOCK_ORG_ID,
email: 'agent@sentryagent.ai', email: 'agent@sentryagent.ai',
agentType: 'screener', agentType: 'screener',
version: '1.0.0', version: '1.0.0',
@@ -117,6 +120,23 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.body = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
await controller.registerAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => { it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.body = { req.body = {
@@ -139,7 +159,7 @@ describe('AgentController', () => {
// ── listAgents ─────────────────────────────────────────────────────────────── // ── listAgents ───────────────────────────────────────────────────────────────
describe('listAgents()', () => { describe('listAgents()', () => {
it('should return 200 with paginated agents', async () => { it('should return 200 with paginated agents scoped to caller org', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.query = { page: '1', limit: '20' }; req.query = { page: '1', limit: '20' };
const paginatedResponse = { data: [MOCK_AGENT], total: 1, page: 1, limit: 20 }; const paginatedResponse = { data: [MOCK_AGENT], total: 1, page: 1, limit: 20 };
@@ -147,6 +167,9 @@ describe('AgentController', () => {
await controller.listAgents(req as Request, res as Response, next); await controller.listAgents(req as Request, res as Response, next);
expect(agentService.listAgents).toHaveBeenCalledWith(
expect.objectContaining({ organizationId: MOCK_ORG_ID }),
);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(paginatedResponse); expect(res.json).toHaveBeenCalledWith(paginatedResponse);
}); });
@@ -160,6 +183,15 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
await controller.listAgents(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when query params are invalid', async () => { it('should call next(ValidationError) when query params are invalid', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.query = { page: 'not-a-number' }; req.query = { page: 'not-a-number' };
@@ -184,13 +216,14 @@ describe('AgentController', () => {
// ── getAgentById ───────────────────────────────────────────────────────────── // ── getAgentById ─────────────────────────────────────────────────────────────
describe('getAgentById()', () => { describe('getAgentById()', () => {
it('should return 200 with the agent', async () => { it('should return 200 with the agent, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
agentService.getAgentById.mockResolvedValue(MOCK_AGENT); agentService.getAgentById.mockResolvedValue(MOCK_AGENT);
await controller.getAgentById(req as Request, res as Response, next); await controller.getAgentById(req as Request, res as Response, next);
expect(agentService.getAgentById).toHaveBeenCalledWith(MOCK_AGENT.agentId, MOCK_ORG_ID);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT); expect(res.json).toHaveBeenCalledWith(MOCK_AGENT);
}); });
@@ -205,6 +238,16 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
await controller.getAgentById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward AgentNotFoundError to next', async () => { it('should forward AgentNotFoundError to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: 'nonexistent' }; req.params = { agentId: 'nonexistent' };
@@ -220,7 +263,7 @@ describe('AgentController', () => {
// ── updateAgent ────────────────────────────────────────────────────────────── // ── updateAgent ──────────────────────────────────────────────────────────────
describe('updateAgent()', () => { describe('updateAgent()', () => {
it('should return 200 with the updated agent', async () => { it('should return 200 with the updated agent, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' }; req.body = { version: '2.0.0' };
@@ -229,6 +272,13 @@ describe('AgentController', () => {
await controller.updateAgent(req as Request, res as Response, next); await controller.updateAgent(req as Request, res as Response, next);
expect(agentService.updateAgent).toHaveBeenCalledWith(
MOCK_AGENT.agentId,
expect.any(Object),
expect.any(String),
expect.any(String),
MOCK_ORG_ID,
);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(updated); expect(res.json).toHaveBeenCalledWith(updated);
}); });
@@ -244,6 +294,17 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' };
await controller.updateAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when body is invalid', async () => { it('should call next(ValidationError) when body is invalid', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
@@ -270,13 +331,19 @@ describe('AgentController', () => {
// ── decommissionAgent ──────────────────────────────────────────────────────── // ── decommissionAgent ────────────────────────────────────────────────────────
describe('decommissionAgent()', () => { describe('decommissionAgent()', () => {
it('should return 204 on success', async () => { it('should return 204 on success, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
agentService.decommissionAgent.mockResolvedValue(); agentService.decommissionAgent.mockResolvedValue();
await controller.decommissionAgent(req as Request, res as Response, next); await controller.decommissionAgent(req as Request, res as Response, next);
expect(agentService.decommissionAgent).toHaveBeenCalledWith(
MOCK_AGENT.agentId,
expect.any(String),
expect.any(String),
MOCK_ORG_ID,
);
expect(res.status).toHaveBeenCalledWith(204); expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled(); expect(res.send).toHaveBeenCalled();
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
@@ -292,6 +359,16 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
await controller.decommissionAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => { it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };

View File

@@ -11,8 +11,7 @@
import express, { Application } from 'express'; import express, { Application } from 'express';
import request from 'supertest'; import request from 'supertest';
import { Pool, PoolClient } from 'pg'; import { HealthDetailedController, HealthDetailedDeps, DbProbe } from '../../../src/controllers/HealthDetailedController';
import { HealthDetailedController, HealthDetailedDeps } from '../../../src/controllers/HealthDetailedController';
// ── fetch mock ──────────────────────────────────────────────────────────────── // ── fetch mock ────────────────────────────────────────────────────────────────
@@ -22,23 +21,19 @@ global.fetch = mockFetch;
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
function makePoolClient(latencyMs = 0, error?: Error): jest.Mocked<Pick<PoolClient, 'query' | 'release'>> { /**
* Creates a mock DbProbe. When `error` is provided, checkLiveness() rejects
* with that error (simulates unreachable DB). Otherwise it resolves after
* `latencyMs` ms (0 by default — Date.now mocking handles degraded scenarios).
*/
function makeDbProbe(error?: Error, latencyMs = 0): DbProbe {
return { return {
query: error checkLiveness: error
? jest.fn().mockRejectedValue(error) ? jest.fn().mockRejectedValue(error)
: jest.fn().mockImplementation(() => : jest.fn().mockImplementation(
new Promise((resolve) => setTimeout(() => resolve({ rows: [], rowCount: 0 }), latencyMs)), () => new Promise<void>((resolve) => setTimeout(() => resolve(), latencyMs)),
), ),
release: jest.fn(), };
} as unknown as jest.Mocked<Pick<PoolClient, 'query' | 'release'>>;
}
function makePool(connectError?: Error, queryLatencyMs = 0, queryError?: Error): jest.Mocked<Pool> {
return {
connect: connectError
? jest.fn().mockRejectedValue(connectError)
: jest.fn().mockResolvedValue(makePoolClient(queryLatencyMs, queryError)),
} as unknown as jest.Mocked<Pool>;
} }
function makeRedisClient(pingError?: Error, latencyMs = 0): { ping(): Promise<string> } { function makeRedisClient(pingError?: Error, latencyMs = 0): { ping(): Promise<string> } {
@@ -67,7 +62,7 @@ beforeEach(() => {
describe('GET /health/detailed — all services healthy', () => { describe('GET /health/detailed — all services healthy', () => {
it('returns 200 with overall status "healthy" when postgres and redis respond quickly', async () => { it('returns 200 with overall status "healthy" when postgres and redis respond quickly', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(undefined, 10), dbProbe: makeDbProbe(undefined, 10),
redisClient: makeRedisClient(undefined, 5), redisClient: makeRedisClient(undefined, 5),
}); });
@@ -81,7 +76,7 @@ describe('GET /health/detailed — all services healthy', () => {
it('includes version and uptime in the response body', async () => { it('includes version and uptime in the response body', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
}); });
@@ -93,7 +88,7 @@ describe('GET /health/detailed — all services healthy', () => {
it('includes latencyMs for each service', async () => { it('includes latencyMs for each service', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
}); });
@@ -146,7 +141,7 @@ describe('GET /health/detailed — degraded scenario', () => {
try { try {
const app = buildApp({ const app = buildApp({
pool: makePool(undefined, 0), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(undefined, 0), redisClient: makeRedisClient(undefined, 0),
}); });
@@ -177,7 +172,7 @@ describe('GET /health/detailed — degraded scenario', () => {
try { try {
const app = buildApp({ const app = buildApp({
pool: makePool(undefined, 0), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(undefined, 0), redisClient: makeRedisClient(undefined, 0),
}); });
@@ -195,7 +190,7 @@ describe('GET /health/detailed — degraded scenario', () => {
describe('GET /health/detailed — unreachable scenarios', () => { describe('GET /health/detailed — unreachable scenarios', () => {
it('returns 503 when postgres connect() throws', async () => { it('returns 503 when postgres connect() throws', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(new Error('ECONNREFUSED')), dbProbe: makeDbProbe(new Error('ECONNREFUSED')),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
}); });
@@ -208,7 +203,7 @@ describe('GET /health/detailed — unreachable scenarios', () => {
it('returns 503 when redis ping() throws', async () => { it('returns 503 when redis ping() throws', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(new Error('Redis ECONNREFUSED')), redisClient: makeRedisClient(new Error('Redis ECONNREFUSED')),
}); });
@@ -221,7 +216,7 @@ describe('GET /health/detailed — unreachable scenarios', () => {
it('returns 503 when both postgres and redis are unreachable', async () => { it('returns 503 when both postgres and redis are unreachable', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(new Error('PG down')), dbProbe: makeDbProbe(new Error('PG down')),
redisClient: makeRedisClient(new Error('Redis down')), redisClient: makeRedisClient(new Error('Redis down')),
}); });
@@ -237,7 +232,7 @@ describe('GET /health/detailed — unreachable scenarios', () => {
describe('GET /health/detailed — optional services omitted when not configured', () => { describe('GET /health/detailed — optional services omitted when not configured', () => {
it('does not include vault in services when vaultAddr is not provided', async () => { it('does not include vault in services when vaultAddr is not provided', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
}); });
@@ -248,7 +243,7 @@ describe('GET /health/detailed — optional services omitted when not configured
it('does not include opa in services when opaUrl is not provided', async () => { it('does not include opa in services when opaUrl is not provided', async () => {
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
}); });
@@ -263,7 +258,7 @@ describe('GET /health/detailed — Vault and OPA probes', () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 })); mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
vaultAddr: 'http://vault:8200', vaultAddr: 'http://vault:8200',
}); });
@@ -278,7 +273,7 @@ describe('GET /health/detailed — Vault and OPA probes', () => {
mockFetch.mockRejectedValue(new Error('Network failure')); mockFetch.mockRejectedValue(new Error('Network failure'));
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
vaultAddr: 'http://vault:8200', vaultAddr: 'http://vault:8200',
}); });
@@ -292,7 +287,7 @@ describe('GET /health/detailed — Vault and OPA probes', () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
opaUrl: 'http://opa:8181', opaUrl: 'http://opa:8181',
}); });
@@ -307,7 +302,7 @@ describe('GET /health/detailed — Vault and OPA probes', () => {
mockFetch.mockResolvedValue(new Response(null, { status: 503 })); mockFetch.mockResolvedValue(new Response(null, { status: 503 }));
const app = buildApp({ const app = buildApp({
pool: makePool(), dbProbe: makeDbProbe(),
redisClient: makeRedisClient(), redisClient: makeRedisClient(),
opaUrl: 'http://opa:8181', opaUrl: 'http://opa:8181',
}); });

View File

@@ -19,7 +19,13 @@ function makePool(queryImpl?: jest.Mock): jest.Mocked<Pool> {
} }
function makeWorker(): jest.Mocked<WebhookDeliveryWorker> { function makeWorker(): jest.Mocked<WebhookDeliveryWorker> {
const worker = new MockWorker({} as never) as jest.Mocked<WebhookDeliveryWorker>; // WebhookDeliveryWorker(pool, vaultClient, redisClient, redisUrl) — pass all required args
const worker = new MockWorker(
{} as never,
null,
{} as never,
'redis://localhost',
) as jest.Mocked<WebhookDeliveryWorker>;
worker.enqueue = jest.fn().mockResolvedValue(undefined); worker.enqueue = jest.fn().mockResolvedValue(undefined);
return worker; return worker;
} }

View File

@@ -27,8 +27,8 @@ function makeAgent(overrides: Partial<IAgent> = {}): IAgent {
isPublic: true, isPublic: true,
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'), updatedAt: new Date('2026-01-02'),
did: null, did: undefined,
didCreatedAt: null, didCreatedAt: undefined,
...overrides, ...overrides,
}; };
} }
@@ -62,7 +62,7 @@ describe('MarketplaceService', () => {
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 }); agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
const result = await service.listPublicAgents(BASE_FILTERS); const result = await service.listPublicAgents(BASE_FILTERS);
const card = result.data[0] as Record<string, unknown>; const card = result.data[0] as unknown as Record<string, unknown>;
expect(card['email']).toBeUndefined(); expect(card['email']).toBeUndefined();
expect(card['organizationId']).toBeUndefined(); expect(card['organizationId']).toBeUndefined();
@@ -79,7 +79,7 @@ describe('MarketplaceService', () => {
}); });
it('should return null DID document when agent has no DID', async () => { it('should return null DID document when agent has no DID', async () => {
const agent = makeAgent({ did: null }); const agent = makeAgent({ did: undefined });
agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 }); agentRepo.findPublicAgents = jest.fn().mockResolvedValue({ agents: [agent], total: 1 });
const result = await service.listPublicAgents(BASE_FILTERS); const result = await service.listPublicAgents(BASE_FILTERS);

View File

@@ -52,7 +52,7 @@ describe('OIDCTrustPolicyService', () => {
const result = await service.createTrustPolicy({ const result = await service.createTrustPolicy({
provider: 'github', provider: 'github',
repository: 'acme/my-repo', repository: 'acme/my-repo',
branch: null, branch: undefined,
agentId: 'agent-001', agentId: 'agent-001',
}); });
@@ -66,7 +66,7 @@ describe('OIDCTrustPolicyService', () => {
service.createTrustPolicy({ service.createTrustPolicy({
provider: 'gitlab' as never, provider: 'gitlab' as never,
repository: 'acme/my-repo', repository: 'acme/my-repo',
branch: null, branch: undefined,
agentId: 'agent-001', agentId: 'agent-001',
}), }),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);
@@ -77,7 +77,7 @@ describe('OIDCTrustPolicyService', () => {
service.createTrustPolicy({ service.createTrustPolicy({
provider: 'github', provider: 'github',
repository: 'no-slash-here', repository: 'no-slash-here',
branch: null, branch: undefined,
agentId: 'agent-001', agentId: 'agent-001',
}), }),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);
@@ -88,7 +88,7 @@ describe('OIDCTrustPolicyService', () => {
service.createTrustPolicy({ service.createTrustPolicy({
provider: 'github', provider: 'github',
repository: 'acme/my-repo', repository: 'acme/my-repo',
branch: null, branch: undefined,
agentId: '', agentId: '',
}), }),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);
@@ -101,7 +101,7 @@ describe('OIDCTrustPolicyService', () => {
service.createTrustPolicy({ service.createTrustPolicy({
provider: 'github', provider: 'github',
repository: 'acme/my-repo', repository: 'acme/my-repo',
branch: null, branch: undefined,
agentId: 'nonexistent', agentId: 'nonexistent',
}), }),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);