ci/woodpecker/pr/ci Pipeline was successful
Implements the ratified hierarchy command surface per contract 1 (hierarchy-schema.md) and contract 2 (rbac-grant-model.md), brief M4-1B-II: - HierarchyRepository: the closed command family (company/estate/ platform-project create/rename/transfer/delete, grant create/change/ revoke, directory + granted-companies reads). Every mutation runs in one transaction through the M4-1b-i audit machinery (event + outbox, idempotency-key replay, causation-linked composite operations). - HierarchyGrantEvaluationService: live deny-by-default evaluation — effective role is the max over ancestor-chain user grants, fail-closed, team subjects suspended (§1.4), platform admin confers no tenant access (§1.1). - companies.visibility column (private default, directory carve-out) with migration 0020, admin-only audited visibility_change (§5.5), closed-field directory listing (§2.8), no-existence-oracle refusals (§6.7). - hierarchy_grants role CHECK pinned to the ratified vocabulary; namespaced serialized roles (hierarchy:*, §4.5). - §1.1 bypass retirement: role-derived MCP scope elevation and hasScope admin shortcuts removed; specs updated to the granted-scope path. - Witnesses: schema-level (role CHECK, visibility class/default), §6.3 closed route inventory, §6.4 per-mutation-class commit+rollback legs, §6.5 authorization, §6.7 oracle indistinguishability, §6.9 visibility, grant-evaluation semantics (chain inheritance, max-role, live revocation).
170 lines
3.6 KiB
TypeScript
170 lines
3.6 KiB
TypeScript
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
|
|
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
|
|
|
/**
|
|
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
|
|
*
|
|
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
|
|
* payload field absent from these classes is a 400. That closure is itself
|
|
* contract surface:
|
|
* - CreateCompanyDto declares NO visibility field — creation is always
|
|
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
|
|
* - CreateGrantDto declares NO teamId field — team grant subjects are
|
|
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
|
|
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
|
* assertion proves the pipe sees the decorators.
|
|
*/
|
|
|
|
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
|
|
|
|
export class CreateCompanyDto {
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
name!: string;
|
|
|
|
@IsString()
|
|
@MaxLength(100)
|
|
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
|
slug!: string;
|
|
|
|
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class RenameNodeDto {
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
name!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class ChangeCompanyVisibilityDto {
|
|
@IsIn(COMPANY_VISIBILITY)
|
|
visibility!: (typeof COMPANY_VISIBILITY)[number];
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class DeleteNodeDto {
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class CreateEstateDto {
|
|
@IsUUID()
|
|
companyId!: string;
|
|
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
name!: string;
|
|
|
|
@IsString()
|
|
@MaxLength(100)
|
|
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
|
slug!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class CreatePlatformProjectDto {
|
|
@IsUUID()
|
|
estateId!: string;
|
|
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
name!: string;
|
|
|
|
@IsString()
|
|
@MaxLength(100)
|
|
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
|
slug!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class TransferEstateDto {
|
|
@IsUUID()
|
|
destinationCompanyId!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class TransferPlatformProjectDto {
|
|
@IsUUID()
|
|
destinationEstateId!: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class CreateGrantDto {
|
|
/** Subject user (better-auth text id). No teamId field — see module doc. */
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
userId!: string;
|
|
|
|
@IsIn(['company', 'estate', 'platform_project'])
|
|
targetKind!: 'company' | 'estate' | 'platform_project';
|
|
|
|
@IsUUID()
|
|
targetId!: string;
|
|
|
|
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
|
|
@IsIn(HIERARCHY_GRANT_ROLES)
|
|
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|
|
|
|
export class ChangeGrantDto {
|
|
@IsIn(HIERARCHY_GRANT_ROLES)
|
|
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MinLength(1)
|
|
@MaxLength(255)
|
|
idempotencyKey?: string;
|
|
}
|