Files
sentryagent-idp/openspec/changes/phase-3-enterprise/specs/multi-tenancy/spec.md
SentryAgent.ai Developer cb7d079ef6 feat(openspec): Phase 3 Enterprise — proposal, design, specs, and tasks
Scaffolds the phase-3-enterprise OpenSpec change (proposal only — awaiting CEO
approval before implementation). 6 workstreams, 95 implementation tasks:

WS1: Multi-Tenancy (21 tasks) — org model, RLS, admin API
WS2: W3C DIDs (12 tasks) — DID:WEB, agent DID documents, AGNTCY cards
WS3: OIDC (12 tasks) — oidc-provider, ID tokens, JWKS, discovery
WS4: Federation (11 tasks) — cross-instance trust, JWT assertions
WS5: Webhooks (17 tasks) — subscriptions, Bull queue, HMAC, retry
WS6: SOC2 (22 tasks) — encryption at rest, Merkle audit chain, controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:53:31 +00:00

445 lines
12 KiB
Markdown

# Multi-Tenancy — Specification
**Workstream**: 1 of 6
**Phase**: 3 — Enterprise
**Author**: Virtual Architect
**Date**: 2026-03-29
---
## Overview
Introduce an Organization model so a single AgentIdP instance serves multiple isolated organizations. Each organization has its own namespace of agents, credentials, audit events, and rate limits. Row-level tenancy in PostgreSQL is enforced by both application-layer `organization_id` filtering and PostgreSQL Row-Level Security (RLS) policies.
All existing endpoints that operate on agents, credentials, or audit events are augmented to be organization-scoped. A new Admin API provides organization lifecycle management. Organization membership controls which agents a caller can manage.
---
## API Endpoints
### POST /organizations
Create a new organization. Requires system-admin scope (`admin:orgs`).
```yaml
POST /organizations
Authorization: Bearer <token with admin:orgs scope>
Content-Type: application/json
Request Body:
schema:
type: object
required: [name, slug]
properties:
name:
type: string
minLength: 2
maxLength: 100
description: Display name of the organization
example: "Acme AI Platform"
slug:
type: string
minLength: 2
maxLength: 50
pattern: "^[a-z0-9-]+$"
description: URL-safe unique identifier
example: "acme-ai"
planTier:
type: string
enum: [free, pro, enterprise]
default: free
maxAgents:
type: integer
minimum: 1
default: 100
maxTokensPerMonth:
type: integer
minimum: 1
default: 10000
Responses:
201 Created:
schema:
$ref: '#/components/schemas/Organization'
example:
organizationId: "org_01HXK7Z9P3FKWABCDEF12345"
name: "Acme AI Platform"
slug: "acme-ai"
planTier: "free"
maxAgents: 100
maxTokensPerMonth: 10000
status: "active"
createdAt: "2026-03-29T12:00:00Z"
updatedAt: "2026-03-29T12:00:00Z"
400 Bad Request:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "slug must be unique"
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INSUFFICIENT_SCOPE"
message: "admin:orgs scope required"
```
---
### GET /organizations
List all organizations. Requires `admin:orgs` scope.
```yaml
GET /organizations
Authorization: Bearer <token with admin:orgs scope>
Query Parameters:
status:
type: string
enum: [active, suspended, deleted]
page:
type: integer
minimum: 1
default: 1
limit:
type: integer
minimum: 1
maximum: 100
default: 20
Responses:
200 OK:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Organization'
total:
type: integer
page:
type: integer
limit:
type: integer
example:
data:
- organizationId: "org_01HXK7Z9P3FKWABCDEF12345"
name: "Acme AI Platform"
slug: "acme-ai"
planTier: "free"
status: "active"
createdAt: "2026-03-29T12:00:00Z"
updatedAt: "2026-03-29T12:00:00Z"
total: 1
page: 1
limit: 20
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
---
### GET /organizations/:orgId
Get a single organization. Requires `admin:orgs` scope or membership in the organization.
```yaml
GET /organizations/{orgId}
Authorization: Bearer <token>
Path Parameters:
orgId:
type: string
description: Organization ID (org_... prefix)
Responses:
200 OK:
schema:
$ref: '#/components/schemas/Organization'
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
404 Not Found:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "ORG_NOT_FOUND"
message: "Organization not found"
```
---
### PATCH /organizations/:orgId
Partially update an organization. Requires `admin:orgs` scope.
```yaml
PATCH /organizations/{orgId}
Authorization: Bearer <token with admin:orgs scope>
Content-Type: application/json
Request Body:
schema:
type: object
properties:
name:
type: string
minLength: 2
maxLength: 100
planTier:
type: string
enum: [free, pro, enterprise]
maxAgents:
type: integer
minimum: 1
maxTokensPerMonth:
type: integer
minimum: 1
status:
type: string
enum: [active, suspended]
Responses:
200 OK:
schema:
$ref: '#/components/schemas/Organization'
400 Bad Request:
schema:
$ref: '#/components/schemas/ErrorResponse'
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
404 Not Found:
schema:
$ref: '#/components/schemas/ErrorResponse'
```
---
### DELETE /organizations/:orgId
Soft-delete an organization (sets status to `deleted`). Requires `admin:orgs` scope. Hard deletion is not supported — data is retained for compliance.
```yaml
DELETE /organizations/{orgId}
Authorization: Bearer <token with admin:orgs scope>
Responses:
204 No Content: {}
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
404 Not Found:
schema:
$ref: '#/components/schemas/ErrorResponse'
409 Conflict:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "ORG_HAS_ACTIVE_AGENTS"
message: "Organization has active agents; decommission all agents before deleting"
```
---
### POST /organizations/:orgId/members
Add a member (agent credential) to an organization. Requires `admin:orgs` scope.
```yaml
POST /organizations/{orgId}/members
Authorization: Bearer <token with admin:orgs scope>
Content-Type: application/json
Request Body:
schema:
type: object
required: [agentId, role]
properties:
agentId:
type: string
description: ID of an already-registered agent to add as a member
role:
type: string
enum: [member, admin]
description: Role within the organization
Responses:
201 Created:
schema:
$ref: '#/components/schemas/OrgMember'
example:
memberId: "mem_01HXK7Z9P3FKWABCDEF99999"
organizationId: "org_01HXK7Z9P3FKWABCDEF12345"
agentId: "agt_01HXK7Z9P3FKWABCDEF67890"
role: "member"
joinedAt: "2026-03-29T12:00:00Z"
400 Bad Request:
schema:
$ref: '#/components/schemas/ErrorResponse'
401 Unauthorized:
schema:
$ref: '#/components/schemas/ErrorResponse'
403 Forbidden:
schema:
$ref: '#/components/schemas/ErrorResponse'
404 Not Found:
schema:
$ref: '#/components/schemas/ErrorResponse'
409 Conflict:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "ALREADY_MEMBER"
message: "Agent is already a member of this organization"
```
---
### Modified: All /agents, /audit endpoints
All existing agent, credential, and audit endpoints now operate within the caller's organization context (extracted from `organization_id` claim in JWT). No URL changes — the scoping is transparent to callers already using the API.
---
## Database Schema Changes
### New Table: organizations
```sql
CREATE TABLE organizations (
organization_id VARCHAR(40) PRIMARY KEY, -- org_... prefixed ULID
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
plan_tier VARCHAR(20) NOT NULL DEFAULT 'free',
max_agents INTEGER NOT NULL DEFAULT 100,
max_tokens_per_month INTEGER NOT NULL DEFAULT 10000,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT organizations_status_check CHECK (status IN ('active', 'suspended', 'deleted')),
CONSTRAINT organizations_plan_check CHECK (plan_tier IN ('free', 'pro', 'enterprise'))
);
CREATE INDEX idx_organizations_slug ON organizations(slug);
CREATE INDEX idx_organizations_status ON organizations(status);
```
### New Table: organization_members
```sql
CREATE TABLE organization_members (
member_id VARCHAR(40) PRIMARY KEY,
organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id),
agent_id VARCHAR(40) NOT NULL REFERENCES agents(agent_id),
role VARCHAR(20) NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT organization_members_role_check CHECK (role IN ('member', 'admin')),
UNIQUE (organization_id, agent_id)
);
CREATE INDEX idx_org_members_org_id ON organization_members(organization_id);
CREATE INDEX idx_org_members_agent_id ON organization_members(agent_id);
```
### Modified: agents table
```sql
ALTER TABLE agents
ADD COLUMN organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id) DEFAULT 'org_system';
CREATE INDEX idx_agents_organization_id ON agents(organization_id);
-- RLS
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
CREATE POLICY agents_org_isolation ON agents
USING (organization_id = current_setting('app.organization_id', true));
```
### Modified: credentials table
```sql
ALTER TABLE credentials
ADD COLUMN organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id) DEFAULT 'org_system';
CREATE INDEX idx_credentials_organization_id ON credentials(organization_id);
ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY credentials_org_isolation ON credentials
USING (organization_id = current_setting('app.organization_id', true));
```
### Modified: audit_logs table
```sql
ALTER TABLE audit_logs
ADD COLUMN organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id) DEFAULT 'org_system';
CREATE INDEX idx_audit_logs_organization_id ON audit_logs(organization_id);
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_logs_org_isolation ON audit_logs
USING (organization_id = current_setting('app.organization_id', true));
```
### Seed: Default system organization
```sql
INSERT INTO organizations (organization_id, name, slug, plan_tier, max_agents, max_tokens_per_month, status)
VALUES ('org_system', 'System', 'system', 'enterprise', 999999, 999999999, 'active');
```
---
## Configuration
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `MULTI_TENANCY_ENABLED` | Enable organization enforcement (set false for single-tenant mode) | `true` |
| `DEFAULT_ORG_ID` | Organization ID to assign pre-tenancy data during migration | `org_system` |
| `MAX_ORGS_PER_INSTANCE` | Hard cap on number of organizations per instance | `1000` |
---
## Dependencies
No new npm packages. Row-level tenancy uses existing PostgreSQL client (`pg`) and query patterns.
---
## Security Considerations
- PostgreSQL RLS is enabled as defense-in-depth — even accidental omission of `organization_id` filter at application layer is caught by the database
- `SET LOCAL app.organization_id` is called at the start of every database transaction
- The `admin:orgs` scope is a new privileged scope — only system-level agent credentials carry it
- Organization slugs are public-facing but organization IDs are internal — never expose organization IDs in public URLs where avoidable
- `DELETE /organizations` is soft-delete only — hard deletion requires a separate admin runbook to prevent accidental data loss
---
## Acceptance Criteria
- [ ] Single AgentIdP instance can serve 2+ organizations with zero cross-organization data leakage
- [ ] All agent/credential/audit operations are scoped to caller's organization_id from JWT
- [ ] PostgreSQL RLS policies verified: direct DB query without app.organization_id setting returns 0 rows
- [ ] Organization CRUD endpoints return correct 403 when caller lacks admin:orgs scope
- [ ] Pre-existing agents assigned to default system organization without data loss
- [ ] TypeScript strict, zero `any`, >80% test coverage on OrgService