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

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