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:
@@ -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
161
src/services/OrgService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user