Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
b38b9846c1 feat(#462): add federation list verb
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
2026-06-24 18:54:47 -05:00
3 changed files with 178 additions and 18 deletions

View File

@@ -1,5 +1,15 @@
import { describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db'; import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js'; import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationListQueryService } from '../list-query.service.js'; import { FederationListQueryService } from '../list-query.service.js';
@@ -12,10 +22,116 @@ const TASK_FILTER: FederationScopeQueryFilter = {
maxRowsPerQuery: 2, maxRowsPerQuery: 2,
}; };
const SUBJECT_USER_ID = 'fed-m3-05-subject';
const OTHER_USER_ID = 'fed-m3-05-other';
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
let dbHandle: DbHandle | undefined;
function makeService() { function makeService() {
return new FederationListQueryService({} as Db); return new FederationListQueryService({} as Db);
} }
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationListQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values({
id: TEAM_ID,
name: 'FED-M3-05 Team',
slug: 'fed-m3-05-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
});
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-05 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-05 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-05 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-05 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
]);
}
function stubRows( function stubRows(
service: FederationListQueryService, service: FederationListQueryService,
...pages: Array<Array<Record<string, unknown>>> ...pages: Array<Array<Record<string, unknown>>>
@@ -37,6 +153,17 @@ function stubRows(
} }
describe('FederationListQueryService', () => { describe('FederationListQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 list reads', async () => { it('denies sensitive resources in native RBAC for M3 list reads', async () => {
const service = makeService(); const service = makeService();
@@ -115,4 +242,40 @@ describe('FederationListQueryService', () => {
'Invalid federation list cursor', 'Invalid federation list cursor',
); );
}); });
it('does not leak another user mission task notes through team-scoped note reads', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
const ids = result.items.map((item) => item['id']);
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
});
it('does not return subject personal mission task notes when includePersonal is false', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
});
}); });

View File

@@ -266,20 +266,18 @@ export class FederationListQueryService implements FederationNativeRbacEvaluator
): Promise<RowObject[]> { ): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter); const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds); const missionIds = await this.listMissionIds(projectIds);
const clauses = [];
if (filter.includePersonal) { if (missionIds.length === 0) {
clauses.push(eq(missionTasks.userId, filter.subjectUserId));
}
if (missionIds.length > 0) {
clauses.push(inArray(missionTasks.missionId, missionIds));
}
if (clauses.length === 0) {
return []; return [];
} }
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses); // mission_tasks rows are user-scoped even when the mission belongs to a team.
// Team visibility can narrow the mission set, but it must never widen the
// query to other users' mission task notes.
const scopeClause = and(
eq(missionTasks.userId, filter.subjectUserId),
inArray(missionTasks.missionId, missionIds),
);
const cursorClause = cursor const cursorClause = cursor
? or( ? or(
lt(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.createdAt, cursor.createdAt),

View File

@@ -15,8 +15,7 @@ Implement `POST /api/federation/v1/list/:resource`.
## Base / branch ## Base / branch
- Branch: `feat/federation-m3-verb-list` - Branch: `feat/federation-m3-verb-list`
- Base: `feat/federation-m3-scope-service` (PR #672), per orchestrator, because M3-04 is not merged yet. - Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
- Rebase target after #672 merges: `main`.
## Implementation notes ## Implementation notes
@@ -28,10 +27,11 @@ Implement `POST /api/federation/v1/list/:resource`.
- `memory`: user-owned `insights` and `preferences` rows. - `memory`: user-owned `insights` and `preferences` rows.
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05. - `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query. - Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
## Tests ## Tests
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (9 tests). - `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (11 tests, including PGlite regression coverage for team-scoped notes isolation and `includePersonal: false`).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS. - `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS. - `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS. - `pnpm format:check` — PASS.
@@ -42,10 +42,9 @@ Implement `POST /api/federation/v1/list/:resource`.
## Review evidence ## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after remediation; approve, no findings. - `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after remediation; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after cursor remediation; risk level none, no findings. - `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after cursor + notes isolation remediation; risk level none, no findings.
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope. - Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
## Risks / follow-up ## Risks / follow-up
- This branch intentionally includes M3-04 diff until PR #672 lands; final PR must be rebased onto main after #672 merges. - Read-path audit logging remains intentionally deferred to M4.
- Current branch base predates the M3-07 capabilities module registration; expect a small `FederationModule` rebase conflict once #672 and #674 are both on main.