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