feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
This commit was merged in pull request #1465.
This commit is contained in:
@@ -4,14 +4,14 @@ import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
|
||||
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
|
||||
* contract 2 merges. This witness enumerates every route the AppModule graph
|
||||
* declares and pins that baseline, so a hierarchy route appearing before its
|
||||
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
|
||||
* baseline is replaced by an exact inventory of the command family.
|
||||
* The hierarchy command family is a CLOSED enumeration asserted here, not a
|
||||
* prose claim: every hierarchy-flavored route the AppModule graph declares
|
||||
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
|
||||
* removing a hierarchy route without updating this inventory (and its
|
||||
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
|
||||
* baseline.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
@@ -77,7 +77,32 @@ function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
return routes;
|
||||
}
|
||||
|
||||
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
/**
|
||||
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
|
||||
* mutation audited via the M4-1b-i path or one of the two ratified reads
|
||||
* (granted companies, the §2.8 directory carve-out).
|
||||
*/
|
||||
const HIERARCHY_COMMAND_FAMILY = [
|
||||
'POST /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies/directory',
|
||||
'POST /api/hierarchy/companies/:id/rename',
|
||||
'POST /api/hierarchy/companies/:id/visibility',
|
||||
'DELETE /api/hierarchy/companies/:id',
|
||||
'POST /api/hierarchy/estates',
|
||||
'POST /api/hierarchy/estates/:id/rename',
|
||||
'POST /api/hierarchy/estates/:id/transfer',
|
||||
'DELETE /api/hierarchy/estates/:id',
|
||||
'POST /api/hierarchy/platform-projects',
|
||||
'POST /api/hierarchy/platform-projects/:id/rename',
|
||||
'POST /api/hierarchy/platform-projects/:id/transfer',
|
||||
'DELETE /api/hierarchy/platform-projects/:id',
|
||||
'POST /api/hierarchy/grants',
|
||||
'POST /api/hierarchy/grants/:id/change',
|
||||
'DELETE /api/hierarchy/grants/:id',
|
||||
] as const;
|
||||
|
||||
describe('hierarchy route inventory (§6.3)', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
@@ -88,19 +113,21 @@ describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
|
||||
const hierarchyRoutes = inventory.filter((r) =>
|
||||
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
|
||||
);
|
||||
expect(
|
||||
hierarchyRoutes,
|
||||
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
|
||||
).toEqual([]);
|
||||
it('the hierarchy surface is exactly the declared command family', () => {
|
||||
const hierarchyRoutes = inventory
|
||||
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
|
||||
it('HierarchyModule itself declares no controllers', () => {
|
||||
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
|
||||
const hierarchyControllers = collectControllers(HierarchyModule);
|
||||
expect(hierarchyControllers).toEqual([]);
|
||||
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
|
||||
const controllers = collectControllers(HierarchyModule);
|
||||
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
|
||||
const declared = controllers
|
||||
.flatMap(routesOf)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,33 @@ describe('CommandAuthorizationService', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
|
||||
.allowed,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
for (const [scope, allowed] of [
|
||||
['core', true],
|
||||
['agent', true],
|
||||
['skill', false],
|
||||
['plugin', false],
|
||||
] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
|
||||
.allowed,
|
||||
).toBe(allowed);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
|
||||
@@ -154,8 +154,15 @@ export class CommandAuthorizationService {
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform admin confers instance administration only —
|
||||
* the former admin-passes-every-scope short-circuit is retired. Admin
|
||||
* reaches exactly the admin scope; core/agent scopes belong to the member
|
||||
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
|
||||
* them (§3.1 deny-by-default).
|
||||
*/
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (role === 'admin') return true;
|
||||
if (scope === 'admin') return role === 'admin';
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import { DB } from '../database/database.module.js';
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
|
||||
* call into this on their own transactions.
|
||||
* hierarchy command repository (HierarchyRepository) is the allowlisted
|
||||
* writer and calls into this on its own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyGrants,
|
||||
hierarchyOutbox,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
workspaces,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
|
||||
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
|
||||
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
|
||||
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
|
||||
* ancestor-chain inheritance, max-role, live revocation, suspended team
|
||||
* subjects). Schema-level constraints are witnessed in
|
||||
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
|
||||
* own atomicity in hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* The rollback legs pre-seed an audit event under the command's idempotency
|
||||
* key with different content: the command's append then throws inside the
|
||||
* command transaction, so the whole mutation must roll back — the command
|
||||
* returns `conflict` and leaves no state change, no second event, and no
|
||||
* second outbox record.
|
||||
*/
|
||||
describe('hierarchy commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyRepository;
|
||||
let evaluation: HierarchyGrantEvaluationService;
|
||||
|
||||
const OWNER = 'hier-cmd-owner';
|
||||
const ADMIN = 'hier-cmd-admin';
|
||||
const STRANGER = 'hier-cmd-stranger';
|
||||
const SUBJECT = 'hier-cmd-subject';
|
||||
|
||||
/** Base fixture: OWNER's company (created through the command surface). */
|
||||
let companyId: string;
|
||||
|
||||
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const eventsForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
|
||||
const outboxForKey = (key: string) =>
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
|
||||
|
||||
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
|
||||
const seedConflictingKey = async (key: string): Promise<void> => {
|
||||
await handle.db.transaction(async (tx) =>
|
||||
appendHierarchyEvent(tx, {
|
||||
actorId: 'seed-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { seeded: true },
|
||||
correlationId: 'seed-correlation',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* §6.4 rollback leg: the command must return `conflict` and leave exactly
|
||||
* the seeded event/outbox pair under the key — nothing it wrote survives.
|
||||
*/
|
||||
const expectRolledBack = async <T>(
|
||||
key: string,
|
||||
command: () => Promise<HierarchyResult<T>>,
|
||||
assertUnchanged: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
await seedConflictingKey(key);
|
||||
const result = await command();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe('conflict');
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
await assertUnchanged();
|
||||
};
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
HierarchyRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyRepository);
|
||||
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
|
||||
]);
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
|
||||
);
|
||||
companyId = created.company.id;
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── §6.4 commit legs ───────────────────────────────────────────────────────
|
||||
|
||||
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const result = expectOk(
|
||||
await repo.createCompany({
|
||||
actorId: OWNER,
|
||||
name: 'Atomic Co',
|
||||
slug: slug('atomic'),
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(result.company.visibility).toBe('private');
|
||||
expect(result.grant.role).toBe('hierarchy:owner');
|
||||
expect(result.grant.userId).toBe(OWNER);
|
||||
expect(result.grant.grantedBy).toBe(OWNER);
|
||||
|
||||
const [createEvents, grantEvents] = await Promise.all([
|
||||
eventsForKey(key),
|
||||
eventsForKey(`${key}:grant`),
|
||||
]);
|
||||
expect(createEvents).toHaveLength(1);
|
||||
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
|
||||
expect(grantEvents).toHaveLength(1);
|
||||
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
|
||||
// The grant event is caused by the create event, same correlation (§4.3).
|
||||
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
|
||||
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, result.company.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
|
||||
);
|
||||
const extraGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: created.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.deleteCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const deleteEvents = await eventsForKey(key);
|
||||
expect(deleteEvents).toHaveLength(1);
|
||||
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
|
||||
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
|
||||
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
|
||||
expect(revokeEvents).toHaveLength(1);
|
||||
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
|
||||
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
|
||||
}
|
||||
expect(
|
||||
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
|
||||
|
||||
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const renamed = expectOk(
|
||||
await repo.renameCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
name: 'New Name Co',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(renamed.company.name).toBe('New Name Co');
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
|
||||
// §6.4: the audited rename carries the old and new names.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
name: 'New Name Co',
|
||||
previousName: 'Old Name Co',
|
||||
});
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, created.company.id));
|
||||
expect(rows[0]!.name).toBe('New Name Co');
|
||||
});
|
||||
|
||||
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const revoked = expectOk(
|
||||
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
|
||||
);
|
||||
expect(revoked.revokedId).toBe(grant.grant.id);
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
// §6 revocation = row deletion: the grant row is gone.
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id));
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolls back a create: no estate row survives the aborted transaction', async () => {
|
||||
const estateSlug = slug('rb-create');
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Doomed Estate',
|
||||
slug: estateSlug,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a rename: the company keeps its name', async () => {
|
||||
const before = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.name).toBe(before.name);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a visibility change: the company stays private', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.visibility).toBe('private');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a transfer: the estate keeps its parent', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
|
||||
);
|
||||
const other = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: other.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(after.companyId).toBe(companyId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a delete: the estate row survives', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant create: no grant row survives', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, STRANGER));
|
||||
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
changeKey,
|
||||
() =>
|
||||
repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
async () => {
|
||||
const row = (
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id))
|
||||
)[0]!;
|
||||
expect(row.role).toBe('viewer');
|
||||
},
|
||||
);
|
||||
const revokeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
revokeKey,
|
||||
() =>
|
||||
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('replays a completed command idempotently through the audit machinery', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
|
||||
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
|
||||
// The retry's insert no-ops on the slug conflict — the command surfaces
|
||||
// `conflict`, and crucially appends no second event under the key.
|
||||
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
|
||||
});
|
||||
|
||||
// ── §6.5 authorization ─────────────────────────────────────────────────────
|
||||
|
||||
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
|
||||
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
// Member also cannot create children (owner-only, §4.1/§4.3).
|
||||
expect(
|
||||
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
|
||||
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: ADMIN,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
|
||||
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
|
||||
});
|
||||
|
||||
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
|
||||
const owned = await repo.changeCompanyVisibility({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
});
|
||||
expect(owned).toEqual({
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
});
|
||||
const changed = expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
|
||||
);
|
||||
expect(changed.company.visibility).toBe('directory');
|
||||
// Restore for later witnesses.
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.9 visibility ────────────────────────────────────────────────────────
|
||||
|
||||
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
|
||||
);
|
||||
const unlisted = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const directory = await repo.listDirectory();
|
||||
const ids = directory.map((entry) => entry.id);
|
||||
expect(ids).toContain(listed.company.id);
|
||||
expect(ids).not.toContain(unlisted.company.id);
|
||||
expect(ids).not.toContain(companyId);
|
||||
// Closed-field: existence, name, slug — nothing else (no visibility, no
|
||||
// timestamps, no grant or membership data).
|
||||
for (const entry of directory) {
|
||||
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
|
||||
}
|
||||
|
||||
// §5.5: the audited event carries old and new values.
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
previousVisibility: 'private',
|
||||
visibility: 'directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
|
||||
);
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
}),
|
||||
);
|
||||
|
||||
// The company is directory-listed for the whole probe window...
|
||||
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
|
||||
|
||||
// ...but the non-granted reader's granted-read surface still excludes it:
|
||||
// directory disclosure adds existence/name/slug only, never content access.
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
|
||||
// A stranger mutation of the listed company is refused exactly like a
|
||||
// missing node — the §6.7 carve-out covers the listing, not commands.
|
||||
const realProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: listed.company.id,
|
||||
name: 'x',
|
||||
});
|
||||
const missingProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realProbe).toEqual(missingProbe);
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
|
||||
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
|
||||
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.7 no existence oracle ───────────────────────────────────────────────
|
||||
|
||||
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
|
||||
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
|
||||
const missingCompany = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realCompany).toEqual(missingCompany);
|
||||
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Oracle E',
|
||||
slug: slug('oracle'),
|
||||
}),
|
||||
);
|
||||
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
|
||||
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
|
||||
expect(realEstate).toEqual(missingEstate);
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
|
||||
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
|
||||
expect(realGrant).toEqual(missingGrant);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
|
||||
|
||||
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
|
||||
);
|
||||
const project = expectOk(
|
||||
await repo.createPlatformProject({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
name: 'Chain P',
|
||||
slug: slug('chain-p'),
|
||||
}),
|
||||
);
|
||||
// Workspaces are evaluable but not hierarchy commands; seed one directly.
|
||||
const workspaceId = randomUUID();
|
||||
await handle.db.insert(workspaces).values({
|
||||
id: workspaceId,
|
||||
name: 'Chain W',
|
||||
slug: slug('chain-w'),
|
||||
platformProjectId: project.platformProject.id,
|
||||
});
|
||||
|
||||
for (const [kind, id] of [
|
||||
['company', companyId],
|
||||
['estate', estate.estate.id],
|
||||
['platform_project', project.platformProject.id],
|
||||
['workspace', workspaceId],
|
||||
] as const) {
|
||||
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
|
||||
}
|
||||
|
||||
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
|
||||
// and below the estate, viewer at the company.
|
||||
const viewerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const ownerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
|
||||
|
||||
// Revocation is row deletion and denies the very next evaluation (§6).
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
|
||||
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
|
||||
expectOk(
|
||||
await repo.deletePlatformProject({
|
||||
actorId: OWNER,
|
||||
platformProjectId: project.platformProject.id,
|
||||
}),
|
||||
);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
|
||||
const teamId = randomUUID();
|
||||
await handle.db.insert(teams).values({
|
||||
id: teamId,
|
||||
name: slug('team'),
|
||||
slug: slug('team'),
|
||||
ownerId: SUBJECT,
|
||||
managerId: SUBJECT,
|
||||
});
|
||||
// Out-of-band team row (the command surface cannot create one).
|
||||
const inserted = await handle.db
|
||||
.insert(hierarchyGrants)
|
||||
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
|
||||
.returning();
|
||||
const teamGrantId = inserted[0]!.id;
|
||||
|
||||
// The team's own owner gains no effective role from it.
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
// changeGrant refuses the row.
|
||||
expect(
|
||||
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: 'team grant subjects are suspended',
|
||||
});
|
||||
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
|
||||
await handle.db.delete(teams).where(eq(teams.id, teamId));
|
||||
});
|
||||
|
||||
// ── command conflict semantics ─────────────────────────────────────────────
|
||||
|
||||
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
|
||||
const source = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
|
||||
);
|
||||
const destination = expectOk(
|
||||
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
|
||||
);
|
||||
const estateSlug = slug('mv');
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: source.company.id,
|
||||
name: 'Mv E',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
|
||||
// OWNER owns the source but not the destination → not_found (§6.7-safe).
|
||||
expect(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
|
||||
// Same-parent transfer is refused.
|
||||
const samePlace = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: source.company.id,
|
||||
});
|
||||
expect(samePlace.ok).toBe(false);
|
||||
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
|
||||
|
||||
// Grant OWNER the destination; a slug collision there is refused.
|
||||
expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: OWNER,
|
||||
targetKind: 'company',
|
||||
targetId: destination.company.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: destination.company.id,
|
||||
name: 'Collide',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
const collision = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
});
|
||||
expect(collision.ok).toBe(false);
|
||||
if (!collision.ok) expect(collision.error).toBe('conflict');
|
||||
});
|
||||
|
||||
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
|
||||
const from = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
|
||||
);
|
||||
const to = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
|
||||
);
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: from.company.id,
|
||||
name: 'Moved E',
|
||||
slug: slug('moved'),
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: to.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const moved = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(moved.companyId).toBe(to.company.id);
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
|
||||
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
|
||||
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
|
||||
// The post-transfer snapshot's parent chain names the destination.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
|
||||
const co = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
|
||||
);
|
||||
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
|
||||
expect(dupSlug.ok).toBe(false);
|
||||
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
|
||||
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: co.company.id,
|
||||
name: 'Child',
|
||||
slug: slug('child'),
|
||||
}),
|
||||
);
|
||||
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
|
||||
expect(withChildren.ok).toBe(false);
|
||||
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
|
||||
|
||||
// Grant to a nonexistent subject is refused (the caller already holds
|
||||
// owner, so the refusal discloses nothing new).
|
||||
const ghost = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: `missing-${randomUUID()}`,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(ghost.ok).toBe(false);
|
||||
if (!ghost.ok) expect(ghost.error).toBe('conflict');
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const duplicate = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(duplicate.ok).toBe(false);
|
||||
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
|
||||
|
||||
const sameRole = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(sameRole.ok).toBe(false);
|
||||
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
|
||||
|
||||
// A second grant with another role exists → changing the first onto that
|
||||
// role would collide with the unique constraint; refused ahead of it.
|
||||
const second = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
const collide = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
});
|
||||
expect(collide.ok).toBe(false);
|
||||
if (!collide.ok) expect(collide.error).toBe('conflict');
|
||||
|
||||
// A clean change succeeds and records the previous role, namespaced (§4.5).
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
const changed = expectOk(
|
||||
await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: second.grant.id,
|
||||
role: 'owner',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
);
|
||||
expect(changed.grant.role).toBe('hierarchy:owner');
|
||||
const events = await eventsForKey(changeKey);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
role: 'hierarchy:owner',
|
||||
previousRole: 'hierarchy:member',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
or,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
and,
|
||||
type Db,
|
||||
HIERARCHY_GRANT_ROLES,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy grant evaluation (contract 2 §3).
|
||||
*
|
||||
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
|
||||
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
|
||||
* the effective role on a node is the maximum role over grants targeting the
|
||||
* node itself or any of its ancestors, maximum per the total order
|
||||
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
|
||||
* caching — so revocation (row deletion, §6) denies the next decision
|
||||
* inherently. A missing node evaluates to null, indistinguishable from
|
||||
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
|
||||
*
|
||||
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
|
||||
* create them and this evaluator considers user-subject grants only, so a
|
||||
* team row could not confer access even if one existed.
|
||||
*
|
||||
* Read-only module: it selects from the class tables but never writes them,
|
||||
* so it does not appear on the writer-coverage allowlist.
|
||||
*/
|
||||
|
||||
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
|
||||
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
|
||||
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
|
||||
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
|
||||
|
||||
type Tx = Pick<Db, 'select'>;
|
||||
|
||||
/** Ancestor chain of a node, self included at its own level; ids only. */
|
||||
export interface AncestorChain {
|
||||
readonly companyId: string;
|
||||
readonly estateId?: string;
|
||||
readonly platformProjectId?: string;
|
||||
readonly workspaceId?: string;
|
||||
}
|
||||
|
||||
export function roleStrength(role: HierarchyGrantRole): number {
|
||||
return HIERARCHY_GRANT_ROLES.indexOf(role);
|
||||
}
|
||||
|
||||
export function roleAtLeast(
|
||||
role: HierarchyGrantRole | null,
|
||||
required: HierarchyGrantRole,
|
||||
): boolean {
|
||||
return role !== null && roleStrength(role) >= roleStrength(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized role strings are namespaced (§4.5): audit events and API
|
||||
* responses carry `hierarchy:owner`, never a bare `owner`.
|
||||
*/
|
||||
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
|
||||
return `hierarchy:${role}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node's ancestor chain (self included). Returns null when the
|
||||
* node does not exist — callers treat that exactly like no-grant (§3.1,
|
||||
* oracle-safe).
|
||||
*/
|
||||
export async function resolveAncestorChain(
|
||||
tx: Tx,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<AncestorChain | null> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.id } : null;
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx
|
||||
.select({ id: estates.id, companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(eq(estates.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.companyId, estateId: row.id } : null;
|
||||
}
|
||||
if (kind === 'platform_project') {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: platformProjects.id,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(platformProjects)
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(platformProjects.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
|
||||
: null;
|
||||
}
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: workspaces.id,
|
||||
platformProjectId: workspaces.platformProjectId,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(workspaces)
|
||||
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(workspaces.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? {
|
||||
companyId: row.companyId,
|
||||
estateId: row.estateId,
|
||||
platformProjectId: row.platformProjectId,
|
||||
workspaceId: row.id,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
|
||||
let best: HierarchyGrantRole | null = null;
|
||||
for (const candidate of roles) {
|
||||
// Fail-closed: a value outside the vocabulary confers nothing.
|
||||
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
|
||||
const role = candidate as HierarchyGrantRole;
|
||||
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role of a user on a node: maximum over the user's grants whose
|
||||
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
|
||||
* node → null.
|
||||
*/
|
||||
export async function evaluateEffectiveRole(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
const chain = await resolveAncestorChain(tx, kind, id);
|
||||
if (!chain) return null;
|
||||
|
||||
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
|
||||
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
|
||||
if (chain.platformProjectId) {
|
||||
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
|
||||
}
|
||||
|
||||
const rows = await tx
|
||||
.select({ role: hierarchyGrants.role })
|
||||
.from(hierarchyGrants)
|
||||
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
|
||||
return maxRole(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/**
|
||||
* All companies on which the user holds any effective role, i.e. companies
|
||||
* with a grant on the company itself or on any descendant (contract 1 §2.8:
|
||||
* a grant anywhere in the subtree discloses the company's chain upward).
|
||||
*/
|
||||
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
|
||||
const grants = await tx
|
||||
.select({
|
||||
companyId: hierarchyGrants.companyId,
|
||||
estateId: hierarchyGrants.estateId,
|
||||
platformProjectId: hierarchyGrants.platformProjectId,
|
||||
})
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, userId));
|
||||
|
||||
const companyIds = new Set<string>();
|
||||
const estateIds = new Set<string>();
|
||||
const platformProjectIds = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
if (grant.companyId) companyIds.add(grant.companyId);
|
||||
else if (grant.estateId) estateIds.add(grant.estateId);
|
||||
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
|
||||
}
|
||||
|
||||
if (platformProjectIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ estateId: platformProjects.estateId })
|
||||
.from(platformProjects)
|
||||
.where(inArray(platformProjects.id, [...platformProjectIds]));
|
||||
for (const row of rows) estateIds.add(row.estateId);
|
||||
}
|
||||
if (estateIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [...estateIds]));
|
||||
for (const row of rows) companyIds.add(row.companyId);
|
||||
}
|
||||
return [...companyIds];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyGrantEvaluationService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
|
||||
effectiveRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
tx?: Tx,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
|
||||
}
|
||||
|
||||
async hasRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
required: HierarchyGrantRole,
|
||||
tx?: Tx,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy.dto.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
|
||||
* closed HTTP surface over the hierarchy class tables: the route-inventory
|
||||
* witness asserts these routes and no others exist. Delete commands take an
|
||||
* optional body (idempotency key) via POST-style DTOs; every mutation is
|
||||
* audited on its own transaction by the repository.
|
||||
*/
|
||||
@Controller('api/hierarchy')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HierarchyController {
|
||||
constructor(
|
||||
private readonly repository: HierarchyRepository,
|
||||
private readonly service: HierarchyService,
|
||||
) {}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('companies')
|
||||
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createCompany({
|
||||
actorId: user.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Companies the caller holds a grant on (directly or via a descendant). */
|
||||
@Get('companies')
|
||||
listGrantedCompanies(@CurrentUser() user: { id: string }) {
|
||||
return this.repository.listGrantedCompanies(user.id);
|
||||
}
|
||||
|
||||
/** Directory-class companies, closed-field (§2.8). */
|
||||
@Get('companies/directory')
|
||||
listDirectory() {
|
||||
return this.repository.listDirectory();
|
||||
}
|
||||
|
||||
@Post('companies/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('companies/:id/visibility')
|
||||
@HttpCode(200)
|
||||
async changeCompanyVisibility(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeCompanyVisibilityDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeCompanyVisibility({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
visibility: dto.visibility,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('companies/:id')
|
||||
async deleteCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('estates')
|
||||
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createEstate({
|
||||
actorId: user.id,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferEstateDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
destinationCompanyId: dto.destinationCompanyId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('estates/:id')
|
||||
async deleteEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
@Post('platform-projects')
|
||||
async createPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() dto: CreatePlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createPlatformProject({
|
||||
actorId: user.id,
|
||||
estateId: dto.estateId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renamePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renamePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferPlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferPlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
destinationEstateId: dto.destinationEstateId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('platform-projects/:id')
|
||||
async deletePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deletePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('grants')
|
||||
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createGrant({
|
||||
actorId: user.id,
|
||||
userId: dto.userId,
|
||||
targetKind: dto.targetKind,
|
||||
targetId: dto.targetId,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('grants/:id/change')
|
||||
@HttpCode(200)
|
||||
async changeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeGrantDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('grants/:id')
|
||||
async revokeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.revokeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyController } from './hierarchy.controller.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
|
||||
* The hierarchy command family — controllers, DTOs, and the allowlisted
|
||||
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
|
||||
* model) merges; until then this module exposes no routes, which the
|
||||
* route-inventory witness asserts.
|
||||
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
|
||||
* M4-1b-ii adds the command family — the closed route surface asserted by
|
||||
* the route-inventory witness — plus grant evaluation (contract 2 §3).
|
||||
* HierarchyRepository is the sole class-table writer (writer-coverage
|
||||
* allowlist); every mutation runs authorize → mutate → audit in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
providers: [HierarchyAuditRepository],
|
||||
exports: [HierarchyAuditRepository],
|
||||
controllers: [HierarchyController],
|
||||
providers: [
|
||||
HierarchyAuditRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
HierarchyRepository,
|
||||
HierarchyService,
|
||||
],
|
||||
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
platformProjects,
|
||||
users,
|
||||
workspaces,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
appendHierarchyEvent,
|
||||
buildNodeSnapshot,
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
import {
|
||||
evaluateEffectiveRole,
|
||||
grantedCompanyIds,
|
||||
namespacedHierarchyRole,
|
||||
roleAtLeast,
|
||||
type GrantTargetKind,
|
||||
type HierarchyGrantRole,
|
||||
} from './hierarchy-grant-evaluation.js';
|
||||
|
||||
/**
|
||||
* Hierarchy command repository (contract 1 §5, contract 2 §4).
|
||||
*
|
||||
* The ONLY writer of the hierarchy class tables (companies, estates,
|
||||
* platform_projects, hierarchy_grants) — it is the writer-coverage
|
||||
* allowlist's sole entry. Every command runs one transaction that
|
||||
* authorizes (live grant evaluation inside the same transaction), mutates,
|
||||
* and appends the semantic audit event + outbox record via the M4-1b-i
|
||||
* machinery, so state, event, and outbox commit or roll back together
|
||||
* (REQ-AUD-001).
|
||||
*
|
||||
* Authorization failure and target-not-found both return `not_found`
|
||||
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
|
||||
* nothing a stranger would not). `forbidden` appears only where the caller
|
||||
* already knows the surface exists independent of any node: the admin-only
|
||||
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
|
||||
*/
|
||||
|
||||
export type HierarchyCommandFailure =
|
||||
| { readonly ok: false; readonly error: 'not_found' }
|
||||
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
|
||||
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
|
||||
|
||||
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
|
||||
|
||||
export interface CompanyView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly visibility: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface GrantView {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly targetKind: GrantTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
|
||||
readonly role: string;
|
||||
readonly grantedBy: string;
|
||||
}
|
||||
|
||||
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
|
||||
export interface DirectoryEntry {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type GrantRow = typeof hierarchyGrants.$inferSelect;
|
||||
|
||||
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
|
||||
|
||||
function conflict(message: string): HierarchyCommandFailure {
|
||||
return { ok: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
|
||||
if (row.companyId) return { kind: 'company', id: row.companyId };
|
||||
if (row.estateId) return { kind: 'estate', id: row.estateId };
|
||||
return { kind: 'platform_project', id: row.platformProjectId as string };
|
||||
}
|
||||
|
||||
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
|
||||
function grantSnapshot(row: GrantRow): Record<string, unknown> {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
subject: { userId: row.userId },
|
||||
target: { kind: target.kind, id: target.id },
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function grantView(row: GrantRow): GrantView {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId as string,
|
||||
targetKind: target.kind,
|
||||
targetId: target.id,
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function companyView(row: typeof companies.$inferSelect): CompanyView {
|
||||
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
readonly actorId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
private async run<T>(
|
||||
idempotencyKey: string | undefined,
|
||||
actorId: string,
|
||||
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
|
||||
): Promise<HierarchyResult<T>> {
|
||||
const ctx: CommandContext = {
|
||||
actorId,
|
||||
idempotencyKey: idempotencyKey ?? randomUUID(),
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => body(tx, ctx));
|
||||
} catch (error) {
|
||||
// A key replayed with different content aborts the whole command —
|
||||
// the transaction (state change included) has rolled back (§6.4).
|
||||
if (error instanceof HierarchyAuditIdempotencyConflictError) {
|
||||
return conflict(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireOwner(
|
||||
tx: Tx,
|
||||
actorId: string,
|
||||
kind: GrantTargetKind,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any authenticated user may create a company; the same audited operation
|
||||
* writes the creator's initial owner grant (§4.3), causation-linked to the
|
||||
* create event. Visibility is always 'private' — the command takes no
|
||||
* visibility input (§5.5).
|
||||
*/
|
||||
createCompany(input: {
|
||||
actorId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const inserted = await tx
|
||||
.insert(companies)
|
||||
.values({ name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const company = inserted[0];
|
||||
if (!company) return conflict('company slug already exists');
|
||||
|
||||
const grantRows = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: ctx.actorId,
|
||||
companyId: company.id,
|
||||
role: 'owner',
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.returning();
|
||||
const grant = grantRows[0] as GrantRow;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
const created = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: created.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:grant`,
|
||||
});
|
||||
return { ok: true, company: companyView(company), grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
renameCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ name: input.name, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
|
||||
* hierarchy mutation a platform admin performs without a grant. A
|
||||
* non-admin caller (owner included) gets `forbidden` before any company
|
||||
* read: the refusal reveals nothing about the target's existence.
|
||||
*/
|
||||
changeCompanyVisibility(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
};
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ visibility: input.visibility, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'visibility_change',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
// Old and new values are event content (§5.5).
|
||||
targetSnapshot: {
|
||||
...snapshot,
|
||||
previousVisibility: previous.visibility,
|
||||
visibility: company.visibility,
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
deleteCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(eq(estates.companyId, input.companyId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('company still has estates');
|
||||
|
||||
// Snapshot and grants are read before the delete; target FKs cascade
|
||||
// the grant rows, and each cascaded deletion is audited (§5.2).
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.companyId, input.companyId));
|
||||
|
||||
await tx.delete(companies).where(eq(companies.id, input.companyId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'company',
|
||||
targetId: input.companyId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.companyId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Child creation requires owner on the parent and confers no grant (§4.3). */
|
||||
createEstate(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(estates)
|
||||
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const estate = inserted[0];
|
||||
if (!estate) return conflict('estate slug already exists in company');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
renameEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(estates)
|
||||
.set({ name: input.name })
|
||||
.where(eq(estates.id, input.estateId))
|
||||
.returning();
|
||||
const estate = updated[0] as typeof estates.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
|
||||
transferEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
destinationCompanyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const estate = rows[0];
|
||||
if (!estate) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (estate.companyId === input.destinationCompanyId) {
|
||||
return conflict('estate already belongs to the destination company');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(
|
||||
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) return conflict('destination company already has that estate slug');
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: companies.id, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
|
||||
const source = parents.find((p) => p.id === estate.companyId);
|
||||
const destination = parents.find((p) => p.id === input.destinationCompanyId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(estates)
|
||||
.set({ companyId: input.destinationCompanyId })
|
||||
.where(eq(estates.id, input.estateId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
deleteEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.estateId, input.estateId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('estate still has platform projects');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.estateId, input.estateId));
|
||||
|
||||
await tx.delete(estates).where(eq(estates.id, input.estateId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.estateId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
createPlatformProject(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(platformProjects)
|
||||
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const project = inserted[0];
|
||||
if (!project) return conflict('platform project slug already exists in estate');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renamePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(platformProjects)
|
||||
.set({ name: input.name })
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.returning();
|
||||
const project = updated[0] as typeof platformProjects.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
transferPlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
destinationEstateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const project = rows[0];
|
||||
if (!project) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (project.estateId === input.destinationEstateId) {
|
||||
return conflict('platform project already belongs to the destination estate');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(platformProjects.estateId, input.destinationEstateId),
|
||||
eq(platformProjects.slug, project.slug),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) {
|
||||
return conflict('destination estate already has that platform project slug');
|
||||
}
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: estates.id, slug: estates.slug })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
|
||||
const source = parents.find((p) => p.id === project.estateId);
|
||||
const destination = parents.find((p) => p.id === input.destinationEstateId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(platformProjects)
|
||||
.set({ estateId: input.destinationEstateId })
|
||||
.where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deletePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.platformProjectId, input.platformProjectId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('platform project still has workspaces');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
|
||||
|
||||
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.platformProjectId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grant management requires effective owner on the target (§4.1). */
|
||||
createGrant(input: {
|
||||
actorId: string;
|
||||
userId: string;
|
||||
targetKind: GrantTargetKind;
|
||||
targetId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const subject = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
if (subject.length === 0) return conflict('subject user does not exist');
|
||||
|
||||
const inserted = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
companyId: input.targetKind === 'company' ? input.targetId : null,
|
||||
estateId: input.targetKind === 'estate' ? input.targetId : null,
|
||||
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
|
||||
role: input.role,
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const grant = inserted[0];
|
||||
if (!grant) return conflict('grant already exists');
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
changeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
// Team subjects are suspended (§1.4); the command surface never
|
||||
// creates them, so this only fires on out-of-band rows.
|
||||
if (!existing.userId) return conflict('team grant subjects are suspended');
|
||||
if (existing.role === input.role) return conflict('grant already holds that role');
|
||||
|
||||
const duplicate = await tx
|
||||
.select({ id: hierarchyGrants.id })
|
||||
.from(hierarchyGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(hierarchyGrants.userId, existing.userId),
|
||||
target.kind === 'company'
|
||||
? eq(hierarchyGrants.companyId, target.id)
|
||||
: target.kind === 'estate'
|
||||
? eq(hierarchyGrants.estateId, target.id)
|
||||
: eq(hierarchyGrants.platformProjectId, target.id),
|
||||
eq(hierarchyGrants.role, input.role),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (duplicate.length > 0) {
|
||||
return conflict('subject already holds that role on the target');
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(hierarchyGrants)
|
||||
.set({ role: input.role })
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.returning();
|
||||
const grant = updated[0] as GrantRow;
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_change',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: {
|
||||
...grantSnapshot(grant),
|
||||
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
|
||||
revokeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ revokedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: existing.id,
|
||||
targetSnapshot: grantSnapshot(existing),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, revokedId: existing.id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The directory: every directory-class company, closed-field (§2.8). The
|
||||
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
|
||||
*/
|
||||
async listDirectory(): Promise<DirectoryEntry[]> {
|
||||
return this.db
|
||||
.select({ id: companies.id, name: companies.name, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(eq(companies.visibility, 'directory'))
|
||||
.orderBy(asc(companies.name));
|
||||
}
|
||||
|
||||
/** Companies the user holds any grant on (company or descendant, §2.8). */
|
||||
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
|
||||
const ids = await grantedCompanyIds(this.db, userId);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, ids))
|
||||
.orderBy(asc(companies.name));
|
||||
return rows.map(companyView);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Maps repository result unions onto HTTP exceptions. `not_found` carries
|
||||
* one fixed message for every cause — missing node and unauthorized caller
|
||||
* are indistinguishable on the wire (contract 1 §6.7).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HierarchyService {
|
||||
unwrap<T>(result: HierarchyResult<T>): T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: HierarchyCommandFailure): Error {
|
||||
switch (failure.error) {
|
||||
case 'not_found':
|
||||
return new NotFoundException('hierarchy node not found');
|
||||
case 'forbidden':
|
||||
return new ForbiddenException(failure.message);
|
||||
case 'conflict':
|
||||
return new ConflictException(failure.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
|
||||
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
|
||||
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
|
||||
const scopes = deriveMcpToolScopesForUser({ role });
|
||||
expect([...scopes].sort()).toEqual([...memberScopes].sort());
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
|
||||
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
|
||||
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
|
||||
// ownership sees exactly what an unprivileged member with the same
|
||||
// ownership would see — here, only the one project they own, and nothing
|
||||
// tenant-wide or platform-wide.
|
||||
const fixtures = {
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant',
|
||||
name: 'same tenant, unowned',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
};
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
// §1.1 retirement: content visibility comes from ownership, not the
|
||||
// tenant-admin role — the acting user owns the tenant-a project.
|
||||
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
// Platform role no longer derives task-write scopes (§1.1 retirement):
|
||||
// a role-derived admin actor is scope-denied before any tenant logic.
|
||||
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
service.registerTools(server, roleDerivedAdmin);
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
|
||||
).rejects.toThrow('MCP tool scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
// The tenant-scoping checks below sit behind the scope gate; exercise
|
||||
// them with explicitly granted task-write scopes (how grant-mapped
|
||||
// scopes will arrive), not with a platform role.
|
||||
tools.clear();
|
||||
const actor = createMcpActorContext({
|
||||
userId: 'tenant-admin-user',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'member',
|
||||
scopes: [
|
||||
...deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
],
|
||||
});
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
// §1.1 retirement: platform-admin no longer derives coord scopes either.
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
await expect(platformAdminTool.handler({})).rejects.toThrow(
|
||||
'MCP tool scope denied: coord:read',
|
||||
);
|
||||
|
||||
// An explicitly granted coord:read scope reaches the server-derived
|
||||
// path (caller-supplied projectPath is stripped by the schema).
|
||||
tools.clear();
|
||||
const grantedActor = createMcpActorContext({
|
||||
userId: 'granted-user',
|
||||
role: 'member',
|
||||
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
|
||||
});
|
||||
service.registerTools(server, grantedActor);
|
||||
const grantedTool = getTool(tools, 'coord_list_tasks');
|
||||
await grantedTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
@@ -63,20 +63,6 @@ interface SessionEntry {
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
@@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
/**
|
||||
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
|
||||
* former tenant-admin/global-admin scope sets keyed on users.role are
|
||||
* retired. Every authenticated user receives the base member set; task
|
||||
* writes and coordination scopes attach to explicit hierarchy grants when
|
||||
* the MCP grant mapping lands, never to a platform role. The role
|
||||
* parameter is kept for caller compatibility and deliberately ignored.
|
||||
*/
|
||||
export function deriveMcpToolScopesForUser(_input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
@@ -168,41 +156,22 @@ type TaskLike = TenantScopedLike & {
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
|
||||
* global-admin/tenant-admin filter short-circuits keyed on the platform role
|
||||
* are retired along with the role-derived scope sets. Content reaches an MCP
|
||||
* actor through ownership only; widened access arrives as explicit hierarchy
|
||||
* grants when the MCP grant mapping lands.
|
||||
*/
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
return projects.filter((project) => project.ownerId === actor.userId);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
return missions.filter((mission) => mission.userId === actor.userId);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
@@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
@@ -302,8 +270,6 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
@@ -317,7 +283,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
@@ -339,7 +304,7 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
if (!refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
@@ -349,8 +314,6 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
@@ -367,7 +330,6 @@ export class McpService implements OnModuleDestroy {
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -43,6 +55,56 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
{
|
||||
name: 'CreateCompanyDto',
|
||||
target: CreateCompanyDto,
|
||||
properties: ['name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'RenameNodeDto',
|
||||
target: RenameNodeDto,
|
||||
properties: ['name', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeCompanyVisibilityDto',
|
||||
target: ChangeCompanyVisibilityDto,
|
||||
properties: ['visibility', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'DeleteNodeDto',
|
||||
target: DeleteNodeDto,
|
||||
properties: ['idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateEstateDto',
|
||||
target: CreateEstateDto,
|
||||
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreatePlatformProjectDto',
|
||||
target: CreatePlatformProjectDto,
|
||||
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferEstateDto',
|
||||
target: TransferEstateDto,
|
||||
properties: ['destinationCompanyId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferPlatformProjectDto',
|
||||
target: TransferPlatformProjectDto,
|
||||
properties: ['destinationEstateId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateGrantDto',
|
||||
target: CreateGrantDto,
|
||||
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeGrantDto',
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "hierarchy_audit_events" DROP CONSTRAINT "hierarchy_audit_events_verb_check";--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD CONSTRAINT "companies_visibility_check" CHECK (visibility IN ('private', 'directory'));--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke'));--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_role_check" CHECK (role IN ('viewer', 'member', 'owner'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1787880918208,
|
||||
"tag": "0019_volatile_killraven",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1787963521142,
|
||||
"tag": "0020_special_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type AnyDb = {
|
||||
|
||||
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
|
||||
const COLUMN_ALLOWLIST: Record<string, string[]> = {
|
||||
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
|
||||
companies: ['id', 'name', 'slug', 'visibility', 'created_at', 'updated_at'],
|
||||
estates: ['id', 'name', 'slug', 'company_id'],
|
||||
platform_projects: ['id', 'name', 'slug', 'estate_id'],
|
||||
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
|
||||
@@ -377,7 +377,61 @@ function witnessSuite(getHandle: () => AnyDb): void {
|
||||
// Control: same subject and target with a different role is a new grant.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
|
||||
.values({ userId: userA, companyId, role: 'member', grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §2.6 role vocabulary CHECK ─────────────────────────────────────────────
|
||||
|
||||
it('refuses a grant role outside the ratified vocabulary, accepts each ratified role', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'superuser', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
// Serialized namespaced forms are storage-invalid too: rows hold bare roles.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'hierarchy:owner', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
for (const role of ['viewer', 'member', 'owner'] as const) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId, role, grantedBy: userA });
|
||||
}
|
||||
await db().execute(
|
||||
sql`DELETE FROM hierarchy_grants WHERE user_id = ${userB} AND estate_id = ${estateId}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §2.8 visibility column ─────────────────────────────────────────────────
|
||||
|
||||
it('defaults companies.visibility to private and refuses values outside the class', async () => {
|
||||
const visId = randomUUID();
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug) VALUES (${visId}, 'Vis', ${T + '-vis'})`,
|
||||
);
|
||||
const res = rows(await db().execute(sql`SELECT visibility FROM companies WHERE id = ${visId}`));
|
||||
expect(res[0]!['visibility']).toBe('private');
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis D', ${T + '-vis-d'}, 'directory')`,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis X', ${T + '-vis-x'}, 'public')`,
|
||||
),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = 'hidden' WHERE id = ${visId}`),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = NULL WHERE id = ${visId}`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
@@ -391,7 +445,7 @@ function witnessSuite(getHandle: () => AnyDb): void {
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'viewer', NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
|
||||
@@ -135,9 +135,9 @@
|
||||
* production code is anomalous and review-visible; that blind spot is
|
||||
* accepted as a residual, not closed.
|
||||
*
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. It is
|
||||
* empty today: the hierarchy command family (M4-1b-ii) has not landed, so no
|
||||
* production module may write the class tables. The infrastructure register
|
||||
* The writer allowlist names hierarchy command/repository modules ONLY. Its
|
||||
* single entry is the M4-1b-ii hierarchy command repository — the sole
|
||||
* production module permitted to write the class tables. The infrastructure register
|
||||
* holds legitimate non-hierarchy raw execution; registered modules are exempt
|
||||
* from prong (iii) only — prongs (i) and (ii) apply to them with no
|
||||
* exemption, and no registered module may appear on the writer allowlist.
|
||||
@@ -181,13 +181,15 @@ const CLASS_TABLES = [
|
||||
|
||||
/**
|
||||
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
|
||||
* EMPTY until the hierarchy command family lands (M4-1b-ii; M4-1b-i ships
|
||||
* only the audit/outbox machinery, which writes no class table). Adding a module
|
||||
* here is a contract-conformance decision reviewed under §5.1 — the module
|
||||
* must be part of the Gateway hierarchy command path, and it must not export
|
||||
* a function that executes caller-supplied SQL.
|
||||
* Adding a module here is a contract-conformance decision reviewed under
|
||||
* §5.1 — the module must be part of the Gateway hierarchy command path, and
|
||||
* it must not export a function that executes caller-supplied SQL.
|
||||
*/
|
||||
const WRITER_ALLOWLIST: string[] = [];
|
||||
const WRITER_ALLOWLIST: string[] = [
|
||||
// The hierarchy command repository (M4-1b-ii): the sole class-table
|
||||
// writer; every mutation is audited on its own transaction (§5.2).
|
||||
'apps/gateway/src/hierarchy/hierarchy.repository.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
|
||||
|
||||
@@ -1063,13 +1063,24 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
|
||||
// command family only (§5.1), enforced by the writer-coverage assertion
|
||||
// (§6.3b) — do not add writers outside that allowlist.
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
/** Company visibility classes (contract 1 §2.8, Ruling 4b): 'private' is the
|
||||
* only creatable class (§5.5 — creation carries no visibility argument);
|
||||
* 'directory' discloses existence/name/slug to all users and is entered only
|
||||
* through the admin-gated visibility-change command. */
|
||||
export const COMPANY_VISIBILITY = ['private', 'directory'] as const;
|
||||
|
||||
export const companies = pgTable(
|
||||
'companies',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
visibility: text('visibility').notNull().default('private'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
() => [check('companies_visibility_check', sql`visibility IN ('private', 'directory')`)],
|
||||
);
|
||||
|
||||
export const estates = pgTable(
|
||||
'estates',
|
||||
@@ -1110,6 +1121,10 @@ export const workspaces = pgTable(
|
||||
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
|
||||
);
|
||||
|
||||
/** Grant role vocabulary (contract 2 §2): totally ordered, viewer ⊂ member ⊂
|
||||
* owner. Order in this tuple IS the ordering — index = strength. */
|
||||
export const HIERARCHY_GRANT_ROLES = ['viewer', 'member', 'owner'] as const;
|
||||
|
||||
export const hierarchyGrants = pgTable(
|
||||
'hierarchy_grants',
|
||||
{
|
||||
@@ -1126,7 +1141,8 @@ export const hierarchyGrants = pgTable(
|
||||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
|
||||
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
|
||||
// totally ordered; CHECK below closes the column to that vocabulary.
|
||||
role: text('role').notNull(),
|
||||
grantedBy: text('granted_by')
|
||||
.notNull()
|
||||
@@ -1134,6 +1150,7 @@ export const hierarchyGrants = pgTable(
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
check('hierarchy_grants_role_check', sql`role IN ('viewer', 'member', 'owner')`),
|
||||
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
|
||||
check(
|
||||
'hierarchy_grants_target_check',
|
||||
@@ -1170,6 +1187,7 @@ export const HIERARCHY_AUDIT_VERBS = [
|
||||
'create',
|
||||
'rename',
|
||||
'transfer',
|
||||
'visibility_change',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
@@ -1220,7 +1238,7 @@ export const hierarchyAuditEvents = pgTable(
|
||||
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'hierarchy_audit_events_verb_check',
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
),
|
||||
check(
|
||||
'hierarchy_audit_events_target_kind_check',
|
||||
|
||||
Reference in New Issue
Block a user