Archived 4 completed OpenSpec changes (2026-04-02): - phase-3-enterprise (100/100 tasks) — 6 Phase 3 capabilities synced - devops-documentation (48/48 tasks) — 3 new + 1 merged capability - bedroom-developer-docs (33/33 tasks) — 4 new capabilities synced - engineering-docs (superseded by 2026-03-29 archive) — no tasks Main spec library grows from 21 → 35 capabilities (+14 new): federation, multi-tenancy, oidc, soc2, w3c-dids, webhooks, database, operations, system-overview, api-reference, core-concepts, developer-guides, quick-start + deployment (merged additive requirements) Active changes: 0 — project board is clear for Phase 4 planning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
445 lines
12 KiB
Markdown
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
|