feat(phase-3): workstream 1 — Multi-Tenancy

Introduces full multi-tenant organization model to AgentIdP:

Schema:
- 6 migrations: organizations + organization_members tables; organization_id FK
  added to agents, credentials, audit_logs; PostgreSQL RLS policies on all three
  tables; system org seed + backfill

API:
- 6 new /api/v1/organizations endpoints (CRUD + members) gated by admin:orgs scope
- OPA scopes.json updated with 6 new org endpoint → admin:orgs mappings

Implementation:
- OrgRepository, OrgService, OrgController, createOrgsRouter
- OrgContextMiddleware: sets app.organization_id session variable so RLS enforces
  per-request org isolation at the database layer
- JWT payload extended with organization_id claim; auth.ts backfills org_system
  for backward-compatible tokens
- New error classes: OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError

Tests: 373 passing, 80.64% branch coverage, zero any types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-30 00:29:32 +00:00
parent cb7d079ef6
commit d252097f71
31 changed files with 3043 additions and 35 deletions

View File

@@ -185,6 +185,7 @@ export class OAuth2Service {
client_id: clientId,
scope,
jti,
organization_id: agent.organizationId ?? 'org_system',
};
const accessToken = signToken(payload, this.privateKey);

161
src/services/OrgService.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Organization Service for SentryAgent.ai AgentIdP.
* Business logic for multi-tenant organization lifecycle management.
*/
import { OrgRepository } from '../repositories/OrgRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import {
IOrganization,
IOrgMember,
ICreateOrgRequest,
IUpdateOrgRequest,
IAddMemberRequest,
IPaginatedOrgsResponse,
IOrgListFilters,
} from '../types/organization.js';
import {
OrgNotFoundError,
OrgHasActiveAgentsError,
AlreadyMemberError,
ValidationError,
AgentNotFoundError,
} from '../utils/errors.js';
/**
* Service for organization (tenant) lifecycle management.
* Enforces business rules: slug uniqueness, agent limits, membership constraints.
*/
export class OrgService {
/**
* @param orgRepository - The organization data repository.
* @param agentRepository - The agent repository (for membership validation).
*/
constructor(
private readonly orgRepository: OrgRepository,
private readonly agentRepository: AgentRepository,
) {}
/**
* Creates a new organization.
* Validates slug uniqueness before inserting.
*
* @param data - The organization creation data.
* @returns The newly created organization record.
* @throws ValidationError with code 'SLUG_ALREADY_EXISTS' if the slug is already taken.
*/
async createOrg(data: ICreateOrgRequest): Promise<IOrganization> {
const existing = await this.orgRepository.findBySlug(data.slug);
if (existing !== null) {
throw new ValidationError('Organization slug is already taken.', {
code: 'SLUG_ALREADY_EXISTS',
slug: data.slug,
});
}
return this.orgRepository.create(data);
}
/**
* Returns a paginated list of organizations.
*
* @param filters - Pagination and optional status filter.
* @returns Paginated organizations response.
*/
async listOrgs(filters: IOrgListFilters): Promise<IPaginatedOrgsResponse> {
const { orgs, total } = await this.orgRepository.findAll(filters);
return {
data: orgs,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Retrieves a single organization by its ID.
*
* @param organizationId - The organization ID.
* @returns The organization record.
* @throws OrgNotFoundError if the organization does not exist.
*/
async getOrg(organizationId: string): Promise<IOrganization> {
const org = await this.orgRepository.findById(organizationId);
if (!org) {
throw new OrgNotFoundError(organizationId);
}
return org;
}
/**
* Partially updates an organization's metadata.
*
* @param organizationId - The organization ID to update.
* @param data - The fields to update.
* @returns The updated organization record.
* @throws OrgNotFoundError if the organization does not exist.
*/
async updateOrg(organizationId: string, data: IUpdateOrgRequest): Promise<IOrganization> {
const existing = await this.orgRepository.findById(organizationId);
if (!existing) {
throw new OrgNotFoundError(organizationId);
}
const updated = await this.orgRepository.update(organizationId, data);
if (!updated) {
throw new OrgNotFoundError(organizationId);
}
return updated;
}
/**
* Soft-deletes an organization.
* Prevents deletion if the organization has any active agents.
*
* @param organizationId - The organization ID to delete.
* @throws OrgNotFoundError if the organization does not exist.
* @throws OrgHasActiveAgentsError if there are active agents in the organization.
*/
async deleteOrg(organizationId: string): Promise<void> {
const existing = await this.orgRepository.findById(organizationId);
if (!existing) {
throw new OrgNotFoundError(organizationId);
}
const activeCount = await this.orgRepository.countActiveAgents(organizationId);
if (activeCount > 0) {
throw new OrgHasActiveAgentsError(organizationId, activeCount);
}
await this.orgRepository.softDelete(organizationId);
}
/**
* Adds an agent as a member of an organization.
* Validates that the organization and agent both exist, and that the agent
* is not already a member.
*
* @param organizationId - The organization ID.
* @param data - The member addition request (agentId + role).
* @returns The created membership record.
* @throws OrgNotFoundError if the organization does not exist.
* @throws AgentNotFoundError if the agent does not exist.
* @throws AlreadyMemberError if the agent is already a member of this organization.
*/
async addMember(organizationId: string, data: IAddMemberRequest): Promise<IOrgMember> {
const org = await this.orgRepository.findById(organizationId);
if (!org) {
throw new OrgNotFoundError(organizationId);
}
const agent = await this.agentRepository.findById(data.agentId);
if (!agent) {
throw new AgentNotFoundError(data.agentId);
}
const existingMember = await this.orgRepository.findMember(organizationId, data.agentId);
if (existingMember !== null) {
throw new AlreadyMemberError(data.agentId, organizationId);
}
return this.orgRepository.addMember(organizationId, data.agentId, data.role);
}
}