feat: guest role for clients and outsiders

A project member can be a GUEST. In a private project a guest only sees
the tickets they take part in (opened, assigned or followed): every other
ticket answers 404, also in my work, realtime changes, blockers and
linked tickets, and project-wide views (activity, statistics, dashboard,
releases, sprints, epics, trash) answer 403. Guests open tickets without
assigning or planning them, and comment, attach, follow and vote on their
own; they change nothing else. In a public project they see what anyone
does. The web gives them only Tablero, Lista and Miembros and loads none
of the project-wide data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 18:02:36 +02:00
parent f60d4c8346
commit 8bf7d47b2e
66 changed files with 607 additions and 130 deletions

View File

@@ -12,6 +12,17 @@ import {
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetTicketScopeQuery } from './queries/get-ticket-scope.query';
import { involves } from './queries/read-access';
/** The changes of the tickets `userId` opened, has or follows. */
async function* onlyInvolving(
changes: AsyncIterableIterator<IssueChange>,
userId: string,
): AsyncIterableIterator<IssueChange> {
for await (const change of changes)
if (involves(change.issue, userId)) yield change;
}
/** Realtime subscriptions, authorised at subscribe time. */
@Injectable()
@@ -26,7 +37,12 @@ export class ChangesService {
projectId: string,
): Promise<AsyncIterableIterator<IssueChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.issues(projectId);
const changes = this.changeFeed.issues(projectId);
const scope: string | null = await this.queryBus.execute(
new GetTicketScopeQuery(viewerId, projectId),
);
// A guest hears only about the tickets they take part in.
return scope ? onlyInvolving(changes, scope) : changes;
}
async watchComments(
@@ -41,7 +57,7 @@ export class ChangesService {
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ActivityReadModel>> {
await this.assertCanView(viewerId, projectId);
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.activity(projectId);
}
@@ -49,7 +65,7 @@ export class ChangesService {
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ReleaseChange>> {
await this.assertCanView(viewerId, projectId);
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.releases(projectId);
}
@@ -57,7 +73,7 @@ export class ChangesService {
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId);
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.sprints(projectId);
}
@@ -77,9 +93,10 @@ export class ChangesService {
private async assertCanView(
viewerId: string,
projectId: string,
wholeProject = false,
): Promise<void> {
const allowed = await this.queryBus.execute(
new CanViewProjectQuery(viewerId, projectId),
new CanViewProjectQuery(viewerId, projectId, wholeProject),
);
if (!allowed) throw new NotFoundError('Project', projectId);
}