Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2473da11 |
+14
-27
@@ -32,11 +32,6 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -49,6 +44,16 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -469,7 +474,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -483,17 +488,8 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -513,7 +509,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -527,17 +523,8 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
|
||||
import { count, eq, sql, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
@@ -16,6 +16,12 @@ import { DB } from '../database/database.module.js';
|
||||
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Advisory lock key serializing bootstrap setup. Arbitrary constant; must only
|
||||
* be unique among advisory lock keys used against this database.
|
||||
*/
|
||||
export const BOOTSTRAP_SETUP_LOCK_KEY = 0x626f6f74; // 'boot'
|
||||
|
||||
@Controller('api/bootstrap')
|
||||
export class BootstrapController {
|
||||
constructor(
|
||||
@@ -31,72 +37,83 @@ export class BootstrapController {
|
||||
|
||||
@Post('setup')
|
||||
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
||||
// Only allow setup when zero users exist
|
||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
||||
if ((result?.total ?? 0) > 0) {
|
||||
throw new ForbiddenException('Setup already completed — users exist');
|
||||
}
|
||||
// #1430: the zero-user check and the admin creation must be one critical
|
||||
// section, or two concurrent setup calls can each create an admin. The
|
||||
// transaction-scoped advisory lock serializes setup across all gateway
|
||||
// instances sharing this database; the second caller blocks on the lock,
|
||||
// then re-reads the count and gets 403.
|
||||
return this.db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${BOOTSTRAP_SETUP_LOCK_KEY})`);
|
||||
|
||||
// Create admin user via BetterAuth API
|
||||
const authApi = this.auth.api as unknown as {
|
||||
createUser: (opts: {
|
||||
body: { name: string; email: string; password: string; role?: string };
|
||||
}) => Promise<{
|
||||
user: { id: string; name: string; email: string };
|
||||
}>;
|
||||
};
|
||||
// Only allow setup when zero users exist
|
||||
const [result] = await tx.select({ total: count() }).from(usersTable);
|
||||
if ((result?.total ?? 0) > 0) {
|
||||
throw new ForbiddenException('Setup already completed — users exist');
|
||||
}
|
||||
|
||||
const created = await authApi.createUser({
|
||||
body: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
role: 'admin',
|
||||
},
|
||||
// Create admin user via BetterAuth API. BetterAuth writes on its own
|
||||
// connection and commits independently of this transaction; the reads
|
||||
// below still see the committed row (READ COMMITTED statement snapshot).
|
||||
const authApi = this.auth.api as unknown as {
|
||||
createUser: (opts: {
|
||||
body: { name: string; email: string; password: string; role?: string };
|
||||
}) => Promise<{
|
||||
user: { id: string; name: string; email: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
const created = await authApi.createUser({
|
||||
body: {
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify user was created
|
||||
const [user] = await tx
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, created.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) throw new InternalServerErrorException('User created but not found');
|
||||
|
||||
// Ensure role is admin (createUser may not set it via BetterAuth)
|
||||
if (user.role !== 'admin') {
|
||||
await tx.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
|
||||
}
|
||||
|
||||
// Generate admin API token
|
||||
const plaintext = randomBytes(32).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
||||
const tokenId = uuid();
|
||||
|
||||
const [token] = await tx
|
||||
.insert(adminTokens)
|
||||
.values({
|
||||
id: tokenId,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
label: 'Initial setup token',
|
||||
scope: 'admin',
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: 'admin',
|
||||
},
|
||||
token: {
|
||||
id: token!.id,
|
||||
plaintext,
|
||||
label: token!.label,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Verify user was created
|
||||
const [user] = await this.db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, created.user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) throw new InternalServerErrorException('User created but not found');
|
||||
|
||||
// Ensure role is admin (createUser may not set it via BetterAuth)
|
||||
if (user.role !== 'admin') {
|
||||
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
|
||||
}
|
||||
|
||||
// Generate admin API token
|
||||
const plaintext = randomBytes(32).toString('hex');
|
||||
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
|
||||
const tokenId = uuid();
|
||||
|
||||
const [token] = await this.db
|
||||
.insert(adminTokens)
|
||||
.values({
|
||||
id: tokenId,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
label: 'Initial setup token',
|
||||
scope: 'admin',
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: 'admin',
|
||||
},
|
||||
token: {
|
||||
id: token!.id,
|
||||
plaintext,
|
||||
label: token!.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
@@ -52,13 +52,22 @@ const mockAuth = {
|
||||
},
|
||||
};
|
||||
|
||||
// Override db.select() so the second query (verify user exists) returns a user.
|
||||
// The bootstrap controller calls select().from() twice:
|
||||
// The controller runs setup inside db.transaction(tx) and first takes the
|
||||
// #1430 advisory lock via tx.execute(). Inside the transaction it calls
|
||||
// select().from() twice:
|
||||
// 1. count() to check zero users → returns [{total: 0}]
|
||||
// 2. select().where().limit() → returns [the created user]
|
||||
// callLog records the tx call order so the lock-before-check invariant is
|
||||
// testable.
|
||||
let selectCallCount = 0;
|
||||
const mockDbWithUser = {
|
||||
const callLog: string[] = [];
|
||||
const mockTx = {
|
||||
execute: () => {
|
||||
callLog.push('execute');
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
select: () => {
|
||||
callLog.push('select');
|
||||
selectCallCount++;
|
||||
return {
|
||||
from: () => {
|
||||
@@ -100,6 +109,9 @@ const mockDbWithUser = {
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const mockDbWithUser = {
|
||||
transaction: <T>(cb: (tx: typeof mockTx) => Promise<T>) => cb(mockTx),
|
||||
};
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -153,6 +165,11 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||
expect(body.user.email).toBe('[email protected]');
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token.plaintext).toBeDefined();
|
||||
|
||||
// #1430: the advisory lock must be taken before the zero-user count, or
|
||||
// two concurrent setups can both pass the check.
|
||||
expect(callLog[0]).toBe('execute');
|
||||
expect(callLog[1]).toBe('select');
|
||||
});
|
||||
|
||||
it('returns 400 when extra forbidden properties are sent', async () => {
|
||||
@@ -188,3 +205,55 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1430 regression: users-exist check runs inside the locked transaction ──
|
||||
|
||||
describe('POST /api/bootstrap/setup — setup already completed', () => {
|
||||
let app: INestApplication;
|
||||
const createUserSpy = vi.fn();
|
||||
const lockedTx = {
|
||||
execute: () => Promise.resolve([]),
|
||||
select: () => ({
|
||||
// count() sees an existing user — the locked re-check must reject.
|
||||
from: () => Promise.resolve([{ total: 1 }]),
|
||||
}),
|
||||
};
|
||||
const mockDbUsersExist = {
|
||||
transaction: <T>(cb: (tx: typeof lockedTx) => Promise<T>) => cb(lockedTx),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [BootstrapController],
|
||||
providers: [
|
||||
{ provide: AUTH, useValue: { api: { createUser: createUserSpy } } },
|
||||
{ provide: DB, useValue: mockDbUsersExist },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 403 and never calls createUser when users already exist', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/bootstrap/setup')
|
||||
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(createUserSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,68 +1,30 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,21 +56,6 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
# Hierarchy Schema Contract (D2)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
|
||||
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
|
||||
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
|
||||
document turns the ratified hierarchy into a concrete schema contract:
|
||||
tables, cardinalities, constraints, and ownership/transfer semantics. It is
|
||||
the prerequisite for the hierarchy command family and for the RBAC grant
|
||||
model (contract 2, `docs/requirements/rbac-grant-model.md`).
|
||||
|
||||
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
|
||||
explicit (§1.1); record class extended to include `hierarchy_grants`
|
||||
(§1.1); provenance corrections on legacy tables and the planning `projects`
|
||||
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
|
||||
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
|
||||
(§3.3); transfer transaction includes its audit write (§4.3); ownership
|
||||
invariant completed via contract 2 with the both-sides rule marked as new
|
||||
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
|
||||
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
|
||||
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
|
||||
MUST (allowlist, command surface, audit, corrected cardinality witness).
|
||||
|
||||
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
|
||||
reconciled with §5.2 — the transaction's writes are the single class-row
|
||||
mutation plus that mutation's §5.2 audit writes (event + outbox record),
|
||||
not "exactly two writes"; §6.3 extended with a closed writer-coverage
|
||||
witness so an unregistered internal writer cannot pass a registered-route
|
||||
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
|
||||
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
|
||||
cites §6.5; measured against `origin/contract/rbac-grants` head
|
||||
`501112d2`.)
|
||||
|
||||
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
|
||||
extended to raw SQL — it now also fails on class-table name literals
|
||||
inside SQL strings or tagged SQL templates outside the allowlist, so a
|
||||
raw-SQL writer that touches no schema symbol is still caught.
|
||||
|
||||
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
|
||||
raw-SQL execution primitive outside the allowlist fails the assertion
|
||||
regardless of its SQL content, closing the evasion where a
|
||||
dynamically constructed table name carries neither a schema symbol nor
|
||||
a class-table literal. The detection claim is now coextensive with
|
||||
what the three prongs statically see.
|
||||
|
||||
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
|
||||
corrected to three (F8); §6.3(b) gains the allowlist composition rules
|
||||
(no generic raw-SQL helper is allowlisted; an allowlisted module may
|
||||
not export caller-supplied-SQL execution) and fails outright on
|
||||
runtime code-construction primitives; the detection claim is scoped
|
||||
honestly to the stated syntactic forms, with evasions beyond static
|
||||
reach assigned to §5.1 review/audit rather than claimed for CI.
|
||||
|
||||
Revision 7 (terra r6 new F9): the false-positive remedy no longer
|
||||
contradicts the composition rules — legitimate non-hierarchy raw
|
||||
execution (e.g. the db package's migration runner) is dispositioned
|
||||
onto a second closed enumerated list, the infrastructure register,
|
||||
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
|
||||
from the writer allowlist, and importable only by registered modules
|
||||
or the operational entry points.
|
||||
|
||||
Revision 8 (terra r7 residual F9): the register's import rule made
|
||||
satisfiable by the live tree — imports are checked re-export-aware
|
||||
(package barrels followed), and each registered module carries its own
|
||||
closed importer enumeration, which may name operational entry points
|
||||
such as the Gateway's startup migration hook; named importers stay
|
||||
subject to prongs (i)/(ii) and gain no writer standing.
|
||||
|
||||
Revision 9 (terra r8 F10): revision 8 called the Gateway database
|
||||
module the runner's "one live importer today". That was false — the
|
||||
measured production importer set has four members. The enumeration
|
||||
example now lists the complete measured set, and the import analysis
|
||||
is extended to resolve literal dynamic `import()` routes, which two of
|
||||
the four members use.
|
||||
|
||||
Scope: the tenancy/authorization structure record class — companies,
|
||||
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
||||
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
||||
semantics (contract 2), roll-up projection semantics (contract 8), kanban
|
||||
planning entities inside workspaces (SOT §5), migration or retirement of
|
||||
legacy flat data (future work; see §1.3).
|
||||
|
||||
## 1. Record class and placement
|
||||
|
||||
1. The **tenancy/authorization structure record class** defined by
|
||||
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
|
||||
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
||||
in the class. Every rule addressed to "the class" in this contract
|
||||
(payload prohibition, mutation path, audit) binds all five tables. Class
|
||||
rows carry parentage, naming, grant, and audit-linkage data only — never
|
||||
task, plan, or any business/orchestration payload.
|
||||
References from business/orchestration rows into the class are limited
|
||||
to exactly one form: the canonical `workspace_id` tenancy column that
|
||||
REQ-TEN-001 requires on every canonical row, referencing
|
||||
`workspaces.id`. No business/orchestration row may reference a company,
|
||||
estate, platform-project, or grant id in any position, and no
|
||||
business/orchestration row may reference a workspace id in any
|
||||
non-tenancy position (dependency, claim target, work subject).
|
||||
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
|
||||
`workspace_id` obligation binds business/orchestration rows and does not
|
||||
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
|
||||
anchor the obligation points at.
|
||||
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
|
||||
in `packages/db/src/schema.ts`) are not part of this class. What A1
|
||||
§8.1.4 pins is narrower: the planning `projects` table and
|
||||
`platform_projects` stay distinct tables. This contract adds, as new
|
||||
policy ratified here: neither `teams` nor `projects` is repurposed as a
|
||||
hierarchy table. Their eventual migration or retirement is future work
|
||||
that no existing REQ assigns; it is out of scope here.
|
||||
|
||||
## 2. Tables and cardinalities
|
||||
|
||||
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
|
||||
The existing `projects` table is Brain planning data (so labeled in
|
||||
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
|
||||
MUST NOT merge the two. (A rename of either remains an implementation-PR
|
||||
decision under A1; this contract pins only that they stay distinct tables.)
|
||||
|
||||
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
||||
created_at, updated_at. N per deployment (D2).
|
||||
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
||||
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
||||
company holds any number of estates.
|
||||
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
|
||||
`estates.id` ON DELETE RESTRICT. Exactly one estate per
|
||||
platform-project; an estate holds any number of platform-projects.
|
||||
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
|
||||
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
|
||||
per workspace. This table is the referent of every `workspace_id` column
|
||||
the SOT requires on canonical rows.
|
||||
5. **Chain resolution is by construction.** Because every parent FK is NOT
|
||||
NULL and single-valued (one FK column, no parentage edge tables, no
|
||||
multi-parent forms, no nullable "detached" states), each workspace
|
||||
resolves to exactly one platform-project → estate → company chain (A1
|
||||
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
|
||||
many children per parent is valid data.
|
||||
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
|
||||
`estates.slug` is unique within its company, `platform_projects.slug`
|
||||
within its estate, `workspaces.slug` within its platform-project
|
||||
(composite unique constraints). Display names are unconstrained beyond
|
||||
NOT NULL.
|
||||
7. No hierarchy table carries a `metadata` jsonb column or any
|
||||
free-form payload field. The columns declared in this section and §3
|
||||
are exhaustive: a class table's column set is exactly its declared set
|
||||
(verified per §6.2) — nothing else (A1 §8.1.2).
|
||||
|
||||
## 3. Grant attachment points
|
||||
|
||||
The grant vocabulary (which roles exist, what each permits, how evaluation
|
||||
and revocation work) is contract 2. This contract pins only the schema
|
||||
shape contract 2 attaches to:
|
||||
|
||||
1. `hierarchy_grants` — id, subject (exactly one of `user_id` → `users.id`,
|
||||
`team_id` → `teams.id`; CHECK-enforced exactly-one-of), target (exactly
|
||||
one of `company_id`, `estate_id`, `platform_project_id`;
|
||||
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
|
||||
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
|
||||
`users.id`, created_at.
|
||||
2. Uniqueness: at most one grant row per (subject, target, role). Because
|
||||
the subject and target columns are nullable by design, ordinary
|
||||
PostgreSQL composite uniqueness treats NULLs as distinct and would not
|
||||
enforce this. The implementation MUST use a single
|
||||
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
|
||||
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
|
||||
equivalent partial unique indexes (one per subject×target form). The
|
||||
pinned Drizzle ORM supports `nullsNotDistinct()`.
|
||||
3. Delete actions are split by column class:
|
||||
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
|
||||
ON DELETE CASCADE — the one permitted cascade in this class. A grant
|
||||
on a deleted node is meaningless and fail-open if retained. Cascaded
|
||||
grant deletions are audited per §5.2.
|
||||
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
|
||||
RESTRICT. The identity contract (§7.3) gates user deletion today and
|
||||
defines no team-deletion rule; this contract does not invent one.
|
||||
These FKs stay RESTRICT until an explicit deletion-and-retention
|
||||
contract ratifies otherwise.
|
||||
4. Workspace-level access is evaluated, not stored here: a grant at any of
|
||||
the three levels evaluates down the chain to workspace-scoped
|
||||
authorization (A1 §8.1.3). No `workspace_id` column exists on
|
||||
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
|
||||
mechanism inside the SOT schema, and the chain adds where grants can be
|
||||
declared, never a bypass.
|
||||
|
||||
## 4. Ownership and transfer
|
||||
|
||||
"Assets are transferable subject to the structure" (PRD Part I §4):
|
||||
|
||||
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
|
||||
workspace → new platform-project, platform-project → new estate, estate
|
||||
→ new company. Nothing else in the class or the SOT changes: business
|
||||
and orchestration rows inside affected workspaces are untouched, keep
|
||||
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
|
||||
"chain maintenance").
|
||||
2. Transfer authorization requires authority over BOTH the source and the
|
||||
destination parent. This both-sides predicate is **new policy
|
||||
introduced by this contract pair** (D2/A1 do not state it); its
|
||||
evaluation semantics are contract 2 §5. The structural half — that the
|
||||
transfer command evaluates it before mutating — binds here.
|
||||
3. A transfer transaction mutates exactly one class-table row — the
|
||||
single-row parent-FK update — and contains, beyond that, only the
|
||||
§5.2 audit writes for that mutation (the audit event and its
|
||||
hierarchy-outbox record, committing in the same transaction). No other
|
||||
class, business, or orchestration row changes. There are no multi-row
|
||||
transfer batches at the schema level; bulk moves are N audited
|
||||
transfers.
|
||||
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
|
||||
grant structure: a "company owner" is a subject with an `owner` grant
|
||||
on that company or an ancestor (contract 2 §2), not a column. The
|
||||
ownership invariant across the contract pair: a node may hold zero
|
||||
direct owner grants (authority can derive from an ancestor grant); node
|
||||
creation names the initial `owner` grant in the same audited operation
|
||||
and the wizard seeds the first company's owner the same way (contract 2
|
||||
§4.3); transfer and revocation semantics are contract 2 §§5–6. This
|
||||
avoids column-encoded authority of the kind the legacy schema carries
|
||||
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
|
||||
`team_members.role` is a further authority field — none of them
|
||||
evaluable under a grant model).
|
||||
|
||||
## 5. Mutation path, audit, and deletion
|
||||
|
||||
1. All hierarchy mutations flow through the same sole-writable-SOT,
|
||||
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
|
||||
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
|
||||
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
||||
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
||||
substitute. Concretely:
|
||||
- Every create, rename, transfer, grant create/change/revoke, and
|
||||
delete — including every grant deletion cascaded by a node delete —
|
||||
emits a semantic audit event carrying actor, verb, target, and (for
|
||||
transfers) source and destination parents, with the correlation,
|
||||
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
|
||||
defines.
|
||||
- The state change and its audit event(s) commit in the same
|
||||
transaction, delivered through a transactional outbox. Hierarchy
|
||||
events are not workspace-scoped rows and do not ride the workspace
|
||||
outbox; they get an equivalent hierarchy outbox under the same
|
||||
append-only, same-transaction rules.
|
||||
- **Deletion-safe linkage:** audit events reference their target by an
|
||||
immutable snapshot (id, slug, and parent chain at event time), never
|
||||
by a foreign key into the class tables, so append-only events survive
|
||||
the deletion of their target.
|
||||
3. Deletion is fail-closed bottom-up: a hierarchy record with children
|
||||
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
|
||||
operation subject to the kanban SOT's own rules and is not granted any
|
||||
new semantics by this contract.
|
||||
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
|
||||
roll-up read mutates nothing — not hierarchy state, and not business or
|
||||
orchestration state: it must not mutate, claim, order, or gate
|
||||
workspace work. Contract 8 owns projection details but cannot narrow
|
||||
this rule. This contract additionally guarantees the chain roll-ups
|
||||
aggregate over is unique and non-null (§2.5).
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3):
|
||||
|
||||
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
|
||||
with a null parent FK refused; insert with one valid parent accepted;
|
||||
two siblings under one parent accepted (the control proving the
|
||||
constraint rejects only what §2.5 forbids); catalog assertion that each
|
||||
child table has exactly one parent-FK column and no parentage edge
|
||||
table exists. Composite slug uniqueness per parent (duplicate slug
|
||||
under same parent refused; same slug under different parents accepted).
|
||||
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
|
||||
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
||||
(subject, target, role) row refused for each of the six subject×target
|
||||
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
||||
`granted_by`, and all `name`/`slug` columns witnessed.
|
||||
2. Column allowlist: an information_schema assertion that each class
|
||||
table's column set is exactly the set declared in §2/§3 — the bounded
|
||||
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
||||
3. Command surface: two witnesses, both required (§5.1). (a) Route
|
||||
inventory: an assertion over the Gateway's registered hierarchy
|
||||
routes/commands proving the registered mutation surface is exactly the
|
||||
declared hierarchy command family — no generic CRUD endpoint. (b)
|
||||
Writer coverage — the closed allowlist a route inventory cannot
|
||||
provide: a static CI assertion over the Gateway and package sources
|
||||
with three prongs, each bound to one explicitly enumerated allowlist
|
||||
of hierarchy command/repository modules. (i) Symbol prong: write
|
||||
references to the class-table schema symbols (insert, update, delete)
|
||||
occur only in allowlisted modules. (ii) Literal prong: a class-table
|
||||
name appearing inside a SQL string or tagged SQL template outside the
|
||||
allowlist fails the assertion — this is what catches a raw-SQL writer
|
||||
that references no schema symbol. (iii) Raw-execution prong: any call
|
||||
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
|
||||
driver-level query/execute) outside the allowlist fails the
|
||||
assertion, regardless of what the SQL string contains or how it is
|
||||
constructed — the call site is statically detectable even when a
|
||||
dynamically assembled table name is not, so a raw writer with a
|
||||
runtime-built identifier is caught by its primitive, not its
|
||||
payload. Two composition rules keep prong (iii) meaningful: the
|
||||
allowlist names hierarchy command/repository modules only — a
|
||||
generic raw-SQL helper or database-utility module is never
|
||||
allowlisted; and an allowlisted module MUST NOT export a function
|
||||
that executes caller-supplied SQL (such an export is itself a
|
||||
raw-execution primitive, and the exporting module is treated as
|
||||
unallowlisted for prong (iii) if it does). Legitimate raw execution
|
||||
that is not a hierarchy writer — e.g. the migration runner in the
|
||||
db package — lives on a second, separately enumerated
|
||||
**infrastructure register**, distinct from the writer allowlist and
|
||||
equally closed. A registered module is exempt from prong (iii) only:
|
||||
prongs (i) and (ii) apply to it with no exemption, so it can hold no
|
||||
class-table schema symbol or class-table SQL literal, and it can
|
||||
never appear on the writer allowlist. To close the laundering path,
|
||||
the same assertion checks imports, and the import analysis is
|
||||
**re-export-aware**: it follows package barrels and re-exports, so a
|
||||
route hidden behind an index module is still a route — and it
|
||||
resolves literal dynamic imports the same way: an
|
||||
`await import('<literal specifier>')` is an import edge like any
|
||||
static import, not an evasion of the analysis (a dynamic import of
|
||||
the db package whose specifier is not a literal fails the assertion
|
||||
outright, because it makes the import graph unanalyzable). A
|
||||
registered module may be imported only by other registered modules
|
||||
or by importers named on that module's own closed importer
|
||||
enumeration in the register — operational entry points such as the
|
||||
migration/bootstrap CLI or the Gateway's startup migration hook.
|
||||
The enumeration names the complete permitted production consumer
|
||||
set, and completeness is measured, not asserted: the migration
|
||||
runner's measured production importer set today has four members —
|
||||
the Gateway database module (reached through the db package
|
||||
barrel), the storage package's Postgres adapter, and two mosaic CLI
|
||||
commands, the fleet-backlog command and the gateway verify command,
|
||||
both routed through literal dynamic imports of the db package — so
|
||||
its enumeration names those four. A module that only receives the
|
||||
runner's functions by parameter injection (the gateway schema-check
|
||||
module takes them as arguments from the verify command) has no
|
||||
import edge of its own and is not enumerated. Any import route
|
||||
outside the enumeration fails the assertion. Being a
|
||||
named importer confers nothing else: the importer stays fully
|
||||
subject to prongs (i) and (ii), gains no writer-allowlist standing,
|
||||
and whether it uses the registered module beyond its operational
|
||||
purpose is a §5.1 review question, not a static claim. Runtime code-construction
|
||||
primitives (`eval`, `new Function`) anywhere in the scanned sources
|
||||
fail the assertion outright, allowlist or not. Schema definitions
|
||||
and generated migrations are excluded from the literal prong; a
|
||||
false positive is resolved in the same PR by adding the module to
|
||||
the one enumerated list its role permits — the writer allowlist for
|
||||
a hierarchy command/repository module, the infrastructure register
|
||||
for non-hierarchy raw execution — never by weakening the assertion,
|
||||
and neither list may take a module the composition rules bar from
|
||||
it. Both lists are closed, and the assertion's detection
|
||||
claim is exactly its prongs: it statically surfaces every writer
|
||||
expressed as a schema-symbol reference, a class-table SQL literal, a
|
||||
raw-execution call site, or runtime code construction. An evasion
|
||||
engineered outside those syntactic forms is a §5.1 violation that
|
||||
review and audit own — the witness does not claim to catch what
|
||||
static analysis cannot see, and any such evasion found later is
|
||||
corrected as a conformance defect, not grandfathered.
|
||||
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
||||
grant create/change/revoke, delete) — the event exists after commit
|
||||
with actor/verb/target and same-transaction atomicity; a rolled-back
|
||||
mutation leaves no event (rollback witness); a node delete's cascaded
|
||||
grant deletions are each covered by events; events survive deletion of
|
||||
their target (query the events of a deleted node).
|
||||
5. Transfer tests: parent-FK update moves the subtree resolution and
|
||||
modifies zero business/orchestration rows (row-count and content
|
||||
assertions on workspace contents before/after); transfer without
|
||||
authority on the source or on the destination side is refused (with
|
||||
contract 2 §7.8).
|
||||
6. Deletion tests: delete with children refused at the database level;
|
||||
delete of a leaf cascades its grants and nothing else; deleting a user
|
||||
or team that is a grant subject (or `granted_by` referent) is refused
|
||||
(RESTRICT witnesses for §3.3).
|
||||
7. Negative tests: no business/orchestration table accepts a company,
|
||||
estate, platform-project, or grant id in any reference position, and
|
||||
none accepts a workspace id in any non-tenancy position; the canonical
|
||||
tenancy FK control — a business row inserted with a valid
|
||||
`workspace_id` succeeds, with an invalid one is refused; roll-up
|
||||
endpoints mutate no canonical state anywhere (assert zero writes across
|
||||
hierarchy AND workspace tables, not hierarchy only); readers see
|
||||
aggregates only over workspaces they are authorized on, with no
|
||||
cross-tenant existence oracles (A1 §8.3 acceptance 3).
|
||||
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
||||
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
||||
in the `test` CI step; mocked specs cannot witness database constraints.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded: hierarchy
|
||||
records carry no owner column — ownership is expressed solely through
|
||||
grants (§4.4) — say "agreed" or name the ownership model you want.
|
||||
Reference in New Issue
Block a user