# 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
