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.
|
# 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
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
# the separate `event: tag` entry.)
|
# 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
|
- &image_build_when
|
||||||
- event: tag
|
- event: tag
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
@@ -49,6 +44,16 @@ variables:
|
|||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
branch: next
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main, next]
|
||||||
@@ -469,7 +474,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -483,17 +488,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
@@ -513,7 +509,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -527,17 +523,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; 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
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomBytes, createHash } from 'node:crypto';
|
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 type { Auth } from '@mosaicstack/auth';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
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 { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||||
import type { BootstrapStatusDto, BootstrapResultDto } 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')
|
@Controller('api/bootstrap')
|
||||||
export class BootstrapController {
|
export class BootstrapController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,72 +37,83 @@ export class BootstrapController {
|
|||||||
|
|
||||||
@Post('setup')
|
@Post('setup')
|
||||||
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
|
||||||
// Only allow setup when zero users exist
|
// #1430: the zero-user check and the admin creation must be one critical
|
||||||
const [result] = await this.db.select({ total: count() }).from(usersTable);
|
// section, or two concurrent setup calls can each create an admin. The
|
||||||
if ((result?.total ?? 0) > 0) {
|
// transaction-scoped advisory lock serializes setup across all gateway
|
||||||
throw new ForbiddenException('Setup already completed — users exist');
|
// 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
|
// Only allow setup when zero users exist
|
||||||
const authApi = this.auth.api as unknown as {
|
const [result] = await tx.select({ total: count() }).from(usersTable);
|
||||||
createUser: (opts: {
|
if ((result?.total ?? 0) > 0) {
|
||||||
body: { name: string; email: string; password: string; role?: string };
|
throw new ForbiddenException('Setup already completed — users exist');
|
||||||
}) => Promise<{
|
}
|
||||||
user: { id: string; name: string; email: string };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const created = await authApi.createUser({
|
// Create admin user via BetterAuth API. BetterAuth writes on its own
|
||||||
body: {
|
// connection and commits independently of this transaction; the reads
|
||||||
name: dto.name,
|
// below still see the committed row (READ COMMITTED statement snapshot).
|
||||||
email: dto.email,
|
const authApi = this.auth.api as unknown as {
|
||||||
password: dto.password,
|
createUser: (opts: {
|
||||||
role: 'admin',
|
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 '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 { Test } from '@nestjs/testing';
|
||||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
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 controller runs setup inside db.transaction(tx) and first takes the
|
||||||
// The bootstrap controller calls select().from() twice:
|
// #1430 advisory lock via tx.execute(). Inside the transaction it calls
|
||||||
|
// select().from() twice:
|
||||||
// 1. count() to check zero users → returns [{total: 0}]
|
// 1. count() to check zero users → returns [{total: 0}]
|
||||||
// 2. select().where().limit() → returns [the created user]
|
// 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;
|
let selectCallCount = 0;
|
||||||
const mockDbWithUser = {
|
const callLog: string[] = [];
|
||||||
|
const mockTx = {
|
||||||
|
execute: () => {
|
||||||
|
callLog.push('execute');
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
select: () => {
|
select: () => {
|
||||||
|
callLog.push('select');
|
||||||
selectCallCount++;
|
selectCallCount++;
|
||||||
return {
|
return {
|
||||||
from: () => {
|
from: () => {
|
||||||
@@ -100,6 +109,9 @@ const mockDbWithUser = {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
const mockDbWithUser = {
|
||||||
|
transaction: <T>(cb: (tx: typeof mockTx) => Promise<T>) => cb(mockTx),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,6 +165,11 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
|||||||
expect(body.user.email).toBe('[email protected]');
|
expect(body.user.email).toBe('[email protected]');
|
||||||
expect(body.token).toBeDefined();
|
expect(body.token).toBeDefined();
|
||||||
expect(body.token.plaintext).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 () => {
|
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);
|
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 {
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
type RequestUser = { id: string; role?: string };
|
|
||||||
|
|
||||||
@Controller('api/teams')
|
@Controller('api/teams')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class TeamsController {
|
export class TeamsController {
|
||||||
constructor(private readonly teams: TeamsService) {}
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: RequestUser) {
|
async list() {
|
||||||
if (user.role === 'admin') {
|
return this.teams.findAll();
|
||||||
return this.teams.findAll();
|
|
||||||
}
|
|
||||||
return this.teams.findAllForUser(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
return this.getAccessibleTeam(teamId, user);
|
return this.teams.findById(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@Get(':teamId/members')
|
||||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
return this.teams.listMembers(teamId);
|
return this.teams.listMembers(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
@Get(':teamId/members/:userId')
|
||||||
async checkMembership(
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
@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);
|
|
||||||
}
|
|
||||||
const isMember = await this.teams.isMember(teamId, userId);
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
return { isMember };
|
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 { 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';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,21 +56,6 @@ export class TeamsService {
|
|||||||
return this.db.select().from(teams);
|
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.
|
* Find a team by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
# RBAC Grant Model Contract
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
|
|
||||||
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
|
|
||||||
estate, and project; grants are evaluated down the chain") and the
|
|
||||||
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
|
|
||||||
This document defines the grant vocabulary, evaluation semantics, and
|
|
||||||
revocation propagation that the hierarchy schema contract
|
|
||||||
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
|
|
||||||
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
|
|
||||||
the meaning of "authority" here; the identity contract
|
|
||||||
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
|
|
||||||
grants nothing.
|
|
||||||
|
|
||||||
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
|
|
||||||
completed — the two existing platform-admin bypass code paths are named as
|
|
||||||
non-conformant and §7.4 retires them; team grant subjects suspended pending
|
|
||||||
a team contract (§1.4, §3.3–3.4, §7.5); no-self-escalation restated with
|
|
||||||
its true rationale and a constructible observable (§4.2, §7.7);
|
|
||||||
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
|
|
||||||
contradiction; A1 quotation corrected; audit-field provenance corrected;
|
|
||||||
principal-position consequence named (§1.3); membership-row,
|
|
||||||
fail-closed-fault, and existence-oracle observables added (§7);
|
|
||||||
role-string namespacing rule added (§4.5); ruling request now names the
|
|
||||||
interpretive resolution of PRD "admins".
|
|
||||||
|
|
||||||
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
|
|
||||||
each hierarchy level confers, how grants evaluate down the chain, how
|
|
||||||
revocation propagates, and who may manage grants. Out of scope: the hierarchy
|
|
||||||
tables themselves (contract 1), workspace-internal membership and its
|
|
||||||
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
|
|
||||||
schema), roll-up projection semantics (contract 8), wizard seeding
|
|
||||||
(contract 3), the team model (suspended here; see §1.4).
|
|
||||||
|
|
||||||
## 1. Three authority layers, none substitutable
|
|
||||||
|
|
||||||
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
|
|
||||||
instance administration — user management, system settings, provider
|
|
||||||
configuration. It is not tenancy authority: holding platform `admin`
|
|
||||||
confers **no implicit hierarchy grant and no workspace authorization**.
|
|
||||||
An operator who should see tenant content holds an explicit, audited
|
|
||||||
grant like anyone else. This is the deny-by-default consequence of A1
|
|
||||||
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
|
|
||||||
`role === 'admin'` check on admin endpoints stays the platform role's
|
|
||||||
only meaning. **Two shipped code paths violate this rule today and are
|
|
||||||
implementation defects this contract makes non-conformant:** (a) the
|
|
||||||
command authorization service short-circuits every command scope to
|
|
||||||
allowed for platform admins
|
|
||||||
(`apps/gateway/src/commands/command-authorization.service.ts`,
|
|
||||||
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
|
|
||||||
scope derivation maps platform `admin` to tenant-admin MCP scopes
|
|
||||||
including task create/update
|
|
||||||
(`apps/gateway/src/mcp/mcp.service.ts`,
|
|
||||||
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
|
|
||||||
§7.4 names them as the surfaces the deny-by-default test retires.
|
|
||||||
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
|
|
||||||
authority at company, estate, or platform-project scope and evaluate down
|
|
||||||
the chain to workspace-scoped authorization (§3 below).
|
|
||||||
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
|
|
||||||
A chain grant confers command authorization over descendant workspaces;
|
|
||||||
it does not create membership rows, and row-level principal positions
|
|
||||||
(task owner, proposer, decision actor) still require ACTIVE workspace
|
|
||||||
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
|
|
||||||
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
|
|
||||||
remove the friction: a chain-granted actor who is not a workspace member
|
|
||||||
may issue the write commands their role implies but cannot occupy a
|
|
||||||
principal position — any command taking a principal argument must name
|
|
||||||
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
|
|
||||||
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
|
|
||||||
`team_id` attachment point, but no ratified contract yet defines the
|
|
||||||
team it would bind: the only existing `teams` table is the legacy global
|
|
||||||
Brain table (own authority columns, no workspace binding, not
|
|
||||||
repurposed per contract 1 §1.3), while the SOT's teams are
|
|
||||||
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
|
|
||||||
company-level grant would be a cross-workspace authority group nothing
|
|
||||||
has ratified. Until a team contract defines the subject (which table,
|
|
||||||
which membership rows, and its relation to D2/REQ-ID-001), creating a
|
|
||||||
grant with a team subject MUST be refused at the command surface (the
|
|
||||||
schema column remains, per contract 1). §3's evaluation semantics for
|
|
||||||
team-conferred grants are specified now so the team contract activates
|
|
||||||
them without amending this one.
|
|
||||||
|
|
||||||
## 2. Role vocabulary
|
|
||||||
|
|
||||||
One vocabulary at every hierarchy level, totally ordered — a higher role
|
|
||||||
includes everything below it:
|
|
||||||
|
|
||||||
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
|
|
||||||
aggregates over descendant workspaces (within contract 8's carve-out
|
|
||||||
bounds); read access to descendant workspace content per the SOT's read
|
|
||||||
command families. No mutation of anything.
|
|
||||||
2. `member` — work: everything `viewer` has, plus write authorization for
|
|
||||||
business/orchestration command families in descendant workspaces (the
|
|
||||||
concrete command-family mapping is implementation work under SOT
|
|
||||||
REQ-ID-001; this contract pins that `member` maps to the workspace write
|
|
||||||
families and nothing structural).
|
|
||||||
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
|
|
||||||
the subtree (create/rename/delete child nodes, transfers per §5), and
|
|
||||||
grant management on the node and its subtree (§4).
|
|
||||||
|
|
||||||
No other value is valid in `hierarchy_grants.role`; the column is
|
|
||||||
constraint-checked against exactly these three. Extending the vocabulary is a
|
|
||||||
contract amendment, not an implementation decision.
|
|
||||||
|
|
||||||
## 3. Evaluation semantics
|
|
||||||
|
|
||||||
1. **Deny by default.** No grant on any ancestor → no authority. There are
|
|
||||||
no implicit grants: not from platform role (§1.1), not from creating a
|
|
||||||
node (§4.3), not from workspace membership (membership without a chain
|
|
||||||
grant confers exactly what the SOT's own membership rules confer inside
|
|
||||||
that workspace, nothing up the chain).
|
|
||||||
2. **Down-the-chain only.** A grant on a node applies to that node and its
|
|
||||||
entire descendant subtree. Nothing evaluates upward or sideways: a grant
|
|
||||||
on an estate says nothing about the parent company or sibling estates.
|
|
||||||
3. **Effective role = maximum.** A subject's effective role at any node is
|
|
||||||
the highest role among grants held directly by the subject's user on
|
|
||||||
that node or any ancestor — and, once the team contract activates team
|
|
||||||
subjects (§1.4), grants held by any team the user is a member of on that
|
|
||||||
node or any ancestor. Roles never subtract — there is no negative/deny
|
|
||||||
grant in this model; revocation is deletion (§6).
|
|
||||||
4. **Team grants follow live membership** (specified now, active only per
|
|
||||||
§1.4). A team grant confers its role on the team's current members,
|
|
||||||
evaluated at decision time. Leaving the team is loss of the grant with
|
|
||||||
§6's propagation bound.
|
|
||||||
5. **Live evaluation, fail closed.** Authorization decisions derive from the
|
|
||||||
live grant and team-membership rows (or from a cache that is invalidated
|
|
||||||
in the same transaction as any grant/membership/hierarchy mutation). A
|
|
||||||
decision path that cannot read grant state denies. No materialized ACL is
|
|
||||||
ever authoritative.
|
|
||||||
6. **Tenant context stays derived from authenticated authority**
|
|
||||||
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
|
|
||||||
request is still authorized against that workspace, with the chain
|
|
||||||
contributing the effective role — never letting the chain become what A1
|
|
||||||
§8.1.3 forbids: "a bypass of workspace authorization".
|
|
||||||
|
|
||||||
## 4. Grant management
|
|
||||||
|
|
||||||
1. Creating, changing, or revoking a grant on a node requires effective
|
|
||||||
`owner` on that node (directly or via any ancestor).
|
|
||||||
2. **No self-escalation.** A grant manager cannot create a grant with a role
|
|
||||||
higher than their own effective role on the target node. Under the §2
|
|
||||||
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
|
|
||||||
the top role — no constructible grant exceeds it); it is stated
|
|
||||||
explicitly so it survives any future amendment that decouples
|
|
||||||
grant-management authority from role height. Its observable is the §7.7
|
|
||||||
audit invariant, not a refusal test.
|
|
||||||
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
|
|
||||||
Creating the first company (the wizard path, contract 3) and any
|
|
||||||
top-level company creation MUST name the initial `owner` grant in the
|
|
||||||
same audited operation — a top-level node has no ancestor to inherit
|
|
||||||
from, so without this the node would be unownable. Creating a child node
|
|
||||||
(estate, platform-project, workspace) requires effective `owner` on the
|
|
||||||
parent (§2.3) and confers no automatic grant; the creator's authority
|
|
||||||
over the new node already follows from §3.2 down-the-chain evaluation.
|
|
||||||
The creating command MAY additionally name an explicit initial grant for
|
|
||||||
a child node; it is not required to.
|
|
||||||
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
|
|
||||||
guarantees, extended by this contract with two further fields: the event
|
|
||||||
carries actor, verb, target, **subject, and role** (subject and role are
|
|
||||||
this contract's addition; contract 1 §5.2 does not enumerate them).
|
|
||||||
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
|
|
||||||
level, `member`/`admin` on `users.role`, and the current command layer
|
|
||||||
uses a third `viewer|member|admin` vocabulary — same strings, different
|
|
||||||
meanings. Any serialized role string (audit events per §4.4, API
|
|
||||||
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
|
|
||||||
`platform:admin`); a bare role string in a serialized artifact is
|
|
||||||
non-conformant.
|
|
||||||
|
|
||||||
## 5. Transfer authority (completes contract 1 §4.2)
|
|
||||||
|
|
||||||
"Authority over BOTH the source and the destination parent" means: effective
|
|
||||||
`owner` on the current parent node (or an ancestor) AND effective `owner` on
|
|
||||||
the destination parent node (or an ancestor), evaluated at transfer time in
|
|
||||||
the transfer's own transaction. One subject must hold both; two cooperating
|
|
||||||
half-authorized subjects are not a transfer protocol this contract defines.
|
|
||||||
|
|
||||||
## 6. Revocation propagation
|
|
||||||
|
|
||||||
1. Revoking a grant (deleting the row), removing a user from a team that
|
|
||||||
carries a grant (once team subjects activate, §1.4), or the cascade
|
|
||||||
deletion of a node's grants during node deletion (contract 1 §3.3) all
|
|
||||||
propagate identically: the authority derived from that grant is gone for
|
|
||||||
every descendant workspace.
|
|
||||||
2. **Bound:** the next authorization decision on any affected transport
|
|
||||||
decides against the revoked grant. Concretely: no new HTTP/MCP command
|
|
||||||
authorized by the revoked grant after the revoking transaction commits;
|
|
||||||
an open Socket.IO connection whose subscriptions depend on the revoked
|
|
||||||
grant is re-evaluated within 30 seconds or at its next inbound message,
|
|
||||||
whichever comes first (same bound as the identity contract's §7.1
|
|
||||||
deactivation rule; same mechanism may serve both).
|
|
||||||
3. Revocation is subtractive only in effect, not in representation: the
|
|
||||||
evaluator never needs tombstones; deletion of the row is the revocation.
|
|
||||||
|
|
||||||
## 7. Verification requirements
|
|
||||||
|
|
||||||
Binding on the implementing PRs (extends A1 §8.3 acceptance 2–3 and
|
|
||||||
contract 1 §6):
|
|
||||||
|
|
||||||
1. Vocabulary: the role CHECK constraint rejects any value outside
|
|
||||||
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
|
|
||||||
the `test` CI step).
|
|
||||||
2. Per-level conferral: for each of the three levels × three roles, a grant
|
|
||||||
yields exactly the implied workspace authorization in a descendant
|
|
||||||
workspace and nothing in a non-descendant workspace (the A1 §8.3
|
|
||||||
"exactly the permissions the chain implies" matrix, enumerated). The
|
|
||||||
matrix includes: a chain grant creates zero workspace-membership rows
|
|
||||||
(assert row counts); a chain-granted non-member is refused as the
|
|
||||||
principal argument of any principal-taking command while their
|
|
||||||
non-principal writes succeed (§1.3); structure reads leak no existence
|
|
||||||
of nodes the reader holds no grant on (no cross-tenant existence
|
|
||||||
oracle, A1 §8.3 acceptance 3).
|
|
||||||
3. Ordering: `owner` ⊇ `member` ⊇ `viewer` behaviorally — each higher role
|
|
||||||
passes every lower role's positive cases.
|
|
||||||
4. Deny-by-default: platform `admin` with no grant reaches no tenant
|
|
||||||
content — asserted against the two §1.1 non-conformant surfaces after
|
|
||||||
their retirement: the command-authorization admin short-circuit and the
|
|
||||||
MCP tenant-admin scope derivation both gone (a platform admin with no
|
|
||||||
grant is refused workspace commands and receives no tenant MCP scopes);
|
|
||||||
workspace member with no chain grant gains nothing outside SOT
|
|
||||||
membership semantics; fresh account reaches nothing (identity contract
|
|
||||||
§1.4 cross-check).
|
|
||||||
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
|
|
||||||
refused at the command surface. On activation by the team contract:
|
|
||||||
user-direct and team-conferred grants combine to the maximum; team-leave
|
|
||||||
drops authority within the §6.2 bound; decision-time evaluation
|
|
||||||
witnessed (grant added → next decision allows; no restart or re-login
|
|
||||||
required).
|
|
||||||
6. Revocation: each revocation path in §6.1 denies the next command on
|
|
||||||
every transport; the socket bound is measured; a cached-authorization
|
|
||||||
implementation proves transactional invalidation (grant revoked and
|
|
||||||
decision made on two distinct physical connections). Fail-closed fault
|
|
||||||
witness for §3.5: with grant state unreadable (fault injection), the
|
|
||||||
decision denies.
|
|
||||||
7. Grant management: non-`owner` cannot mutate grants; top-level company
|
|
||||||
creation without the named initial `owner` grant is refused, while child
|
|
||||||
node creation under ancestor authority succeeds without one (§4.3 both
|
|
||||||
directions); every mutation produces its audit event with the §4.4
|
|
||||||
fields. Self-escalation observable: over the audit event stream, every
|
|
||||||
grant-create/change event's role is ≤ the acting user's effective role
|
|
||||||
on the target at event time (reconstructable invariant, not a refusal
|
|
||||||
test — see §4.2).
|
|
||||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
|
||||||
(completing contract 1 §6.5).
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–7 as written, with one decision embedded and one
|
|
||||||
interpretive resolution named:
|
|
||||||
|
|
||||||
- Decision: platform `admin` confers no implicit tenant access — operators
|
|
||||||
see tenant content only through explicit, audited grants (§1.1), which
|
|
||||||
retires the two existing admin bypass paths named there. Say "agreed" or
|
|
||||||
name the implicit access you want platform admins to keep.
|
|
||||||
- Interpretive resolution (for visibility, not a separate question): PRD
|
|
||||||
Part I §4 says "admins restrict access per company, estate, and project";
|
|
||||||
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
|
|
||||||
platform admins. A1 §8.1.3 does not attribute grant declaration to
|
|
||||||
platform admins, and the §1.1 decision above is what makes this reading
|
|
||||||
binding.
|
|
||||||
Reference in New Issue
Block a user