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>
12 KiB
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).
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.
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.
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.
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.
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.
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
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
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
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
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
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
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_idfilter at application layer is caught by the database SET LOCAL app.organization_idis called at the start of every database transaction- The
admin:orgsscope 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 /organizationsis 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