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

@@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler
constructor(private readonly access: ReadAccess) {}
async execute(query: CanViewProjectQuery): Promise<boolean> {
return this.access.canView(query.projectId, query.viewerId);
if (!(await this.access.canView(query.projectId, query.viewerId)))
return false;
return (
!query.wholeProject ||
!(await this.access.participantScope(query.projectId, query.viewerId))
);
}
}

View File

@@ -5,6 +5,8 @@ export class CanViewProjectQuery extends Query<boolean> {
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
/** The whole project (activity, releases…), not only a guest's tickets. */
public readonly wholeProject = false,
) {
super();
}

View File

@@ -1,3 +1,5 @@
import { NotFoundError } from '@task-stream/api/shared/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import { FindActivityRepository } from '../ports/find-activity.repository';
@@ -11,10 +13,23 @@ export class GetActivityQueryHandler
constructor(
private readonly findActivity: FindActivityRepository,
private readonly access: ReadAccess,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
// A ticket's history goes with the ticket; the project's feed is for
// whoever sees the whole project (not a guest).
if (query.issueId) {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
if (issue.projectId !== query.projectId)
throw new NotFoundError('Issue', query.issueId);
await this.access.assertCanViewIssue(issue, query.viewerId);
} else
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findActivity.find({
projectId: query.projectId,
issueId: query.issueId,

View File

@@ -21,7 +21,7 @@ export class GetAttachmentQueryHandler
'Attachment',
query.attachmentId,
);
await this.access.assertCanView(issue.projectId, query.viewerId, {
await this.access.assertCanViewIssue(issue, query.viewerId, {
entity: 'Attachment',
ref: query.attachmentId,
});

View File

@@ -27,6 +27,11 @@ export class GetBoardQueryHandler
*/
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
const participantId =
(await this.access.participantScope(
query.filter.projectId,
query.viewerId,
)) ?? undefined;
const project = await this.findProjects.findById(query.filter.projectId);
const statuses = (
project?.settings ?? DEFAULT_PROJECT_SETTINGS
@@ -35,6 +40,7 @@ export class GetBoardQueryHandler
statuses.map(async (status) => {
const page = await this.findIssues.search({
...query.filter,
participantId,
statuses: [status.id],
// Each column follows the backlog order.
sort: IssueSort.RANK,

View File

@@ -21,7 +21,7 @@ export class GetCommentsQueryHandler
'Issue',
query.issueId,
);
await this.access.assertCanView(issue.projectId, query.viewerId, {
await this.access.assertCanViewIssue(issue, query.viewerId, {
entity: 'Issue',
ref: query.issueId,
});

View File

@@ -14,7 +14,7 @@ export class GetEpicsQueryHandler
) {}
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.epics(query.projectId);
}
}

View File

@@ -18,7 +18,7 @@ export class GetIssueQueryHandler
? await this.findIssues.findById(query.idOrKey)
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
await this.access.assertCanView(found.projectId, query.viewerId, {
await this.access.assertCanViewIssue(found, query.viewerId, {
entity: 'Issue',
ref: query.idOrKey,
});

View File

@@ -23,6 +23,13 @@ export class GetIssuesByIdsQueryHandler
await this.access.canView(projectId, query.viewerId),
);
}
return issues.filter((issue) => visible.get(issue.projectId));
const shown: IssueReadModel[] = [];
for (const issue of issues)
if (
visible.get(issue.projectId) &&
(await this.access.canViewIssue(issue, query.viewerId))
)
shown.push(issue);
return shown;
}
}

View File

@@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler
async execute(
query: GetProjectAnalyticsQuery,
): Promise<ProjectAnalyticsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
const [project, issues, timeline] = await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),

View File

@@ -32,7 +32,7 @@ export class GetProjectInsightsQueryHandler
async execute(
query: GetProjectInsightsQuery,
): Promise<ProjectInsightsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
const [project, issues, timeline, sprints, releases, mentions] =
await Promise.all([
this.findProjects.findById(query.projectId),

View File

@@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler
) {}
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.stats(query.projectId);
}
}

View File

@@ -24,10 +24,14 @@ export class GetReleaseBurnupQueryHandler
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId, {
entity: 'Release',
ref: query.releaseId,
});
await this.access.assertSeesWholeProject(
release.projectId,
query.viewerId,
{
entity: 'Release',
ref: query.releaseId,
},
);
const [issues, timeline] = await Promise.all([
this.findIssues.findByProject(release.projectId),
this.findTimeline.findByProject(release.projectId),

View File

@@ -36,10 +36,14 @@ export class GetReleaseNotesDraftQueryHandler
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId, {
entity: 'Release',
ref: query.releaseId,
});
await this.access.assertSeesWholeProject(
release.projectId,
query.viewerId,
{
entity: 'Release',
ref: query.releaseId,
},
);
const [page, project] = await Promise.all([
this.findIssues.search({
projectId: release.projectId,

View File

@@ -23,10 +23,14 @@ export class GetReleaseProgressQueryHandler
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId, {
entity: 'Release',
ref: query.releaseId,
});
await this.access.assertSeesWholeProject(
release.projectId,
query.viewerId,
{
entity: 'Release',
ref: query.releaseId,
},
);
return this.findIssues.releaseProgress(release.id);
}
}

View File

@@ -19,10 +19,14 @@ export class GetReleaseQueryHandler
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId, {
entity: 'Release',
ref: query.releaseId,
});
await this.access.assertSeesWholeProject(
release.projectId,
query.viewerId,
{
entity: 'Release',
ref: query.releaseId,
},
);
return release;
}
}

View File

@@ -14,7 +14,7 @@ export class GetReleasesQueryHandler
) {}
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findReleases.findByProject(query.projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetTicketScopeQuery } from './get-ticket-scope.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetTicketScopeQuery)
export class GetTicketScopeQueryHandler
implements IQueryHandler<GetTicketScopeQuery, string | null>
{
constructor(private readonly access: ReadAccess) {}
execute(query: GetTicketScopeQuery): Promise<string | null> {
return this.access.participantScope(query.projectId, query.viewerId);
}
}

View File

@@ -0,0 +1,14 @@
import { Query } from '@nestjs/cqrs';
/**
* Whose tickets the viewer sees of a project: null for all; their own id
* for a guest of a private project (only the tickets they take part in).
*/
export class GetTicketScopeQuery extends Query<string | null> {
constructor(
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -23,7 +23,7 @@ export class GetTrashQueryHandler
) {}
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.findTrashed(query.projectId);
}
}

View File

@@ -1,3 +1,4 @@
import { guestOnly } from './read-access';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -15,8 +16,11 @@ export class GetUpcomingReleasesQueryHandler
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
const projects = await this.findProjects.findByMember(query.viewerId);
// A guest does not see a private project's releases.
return this.findReleases.findUpcoming(
projects.map((project) => project.id),
projects
.filter((project) => !guestOnly(project, query.viewerId))
.map((project) => project.id),
Math.min(query.limit, 50),
);
}

View File

@@ -7,6 +7,7 @@ export * from './get-issues-by-ids.query';
export * from './get-my-projects.query';
export * from './get-project-analytics.query';
export * from './get-project-insights.query';
export * from './get-ticket-scope.query';
export * from './get-project-stats.query';
export * from './get-release-burnup.query';
export * from './get-project.query';

View File

@@ -10,6 +10,7 @@ import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler';
@@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [
GetProjectQueryHandler,
GetProjectAnalyticsQueryHandler,
GetProjectInsightsQueryHandler,
GetTicketScopeQueryHandler,
GetProjectStatsQueryHandler,
GetReleaseBurnupQueryHandler,
GetReleaseQueryHandler,

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import {
IssueReadModel,
ProjectReadModel,
ProjectRole,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
/**
@@ -35,6 +39,67 @@ export class ReadAccess {
return null;
}
/**
* Whose tickets the viewer sees of the project: null for all of them; for
* a guest of a private project, their own id (only the tickets they take
* part in).
*/
async participantScope(
projectId: string,
viewerId: string | null,
): Promise<string | null> {
if (!viewerId) return null;
const role = await this.findProjects.findRole(projectId, viewerId);
if (role !== ProjectRole.GUEST) return null;
const project = await this.findProjects.findById(projectId);
return project?.isPublic ? null : viewerId;
}
/**
* Views of the whole project (activity, statistics, releases, sprints…):
* not for a guest, who only sees their own tickets.
*/
async assertSeesWholeProject(
projectId: string,
viewerId: string | null,
asked?: { entity: string; ref: string },
): Promise<ProjectRole | null> {
const role = await this.assertCanView(projectId, viewerId, asked);
if (await this.participantScope(projectId, viewerId))
throw new ForbiddenError(
'Un invitado solo ve los tickets en los que participa',
);
return role;
}
/** The ticket, if the viewer may read it (a guest: only theirs). */
async assertCanViewIssue(
issue: IssueReadModel,
viewerId: string | null,
asked: { entity: string; ref: string } = {
entity: 'Issue',
ref: issue.key,
},
): Promise<void> {
await this.assertCanView(issue.projectId, viewerId, asked);
const scope = await this.participantScope(issue.projectId, viewerId);
if (scope && !involves(issue, scope))
throw new NotFoundError(asked.entity, asked.ref);
}
async canViewIssue(
issue: IssueReadModel,
viewerId: string | null,
): Promise<boolean> {
try {
await this.assertCanViewIssue(issue, viewerId);
return true;
} catch (error) {
if (error instanceof NotFoundError) return false;
throw error;
}
}
async canView(projectId: string, viewerId: string | null): Promise<boolean> {
try {
await this.assertCanView(projectId, viewerId);
@@ -51,5 +116,24 @@ export class ReadAccess {
}
}
/** Opened it, has it or follows it. */
export function involves(issue: IssueReadModel, userId: string): boolean {
return (
issue.reporter?.id === userId ||
issue.assignee?.id === userId ||
(issue.watchers ?? []).some((w) => w.id === userId)
);
}
/** A guest of this private project: sees only the tickets they take part in. */
export function guestOnly(project: ProjectReadModel, userId: string): boolean {
return (
!project.isPublic &&
project.members.some(
(m) => m.user.id === userId && m.role === ProjectRole.GUEST,
)
);
}
export const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -20,6 +20,11 @@ export class SearchIssuesQueryHandler
if (watching && !query.viewerId) return { items: [], total: 0 };
return this.findIssues.search({
...filter,
participantId:
(await this.access.participantScope(
query.filter.projectId,
query.viewerId,
)) ?? undefined,
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
limit: Math.min(query.filter.limit, 200),
});

View File

@@ -7,6 +7,7 @@ import {
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SearchMyIssuesQuery } from './search-my-issues.query';
import { guestOnly } from './read-access';
@QueryHandler(SearchMyIssuesQuery)
export class SearchMyIssuesQueryHandler
@@ -41,8 +42,13 @@ export class SearchMyIssuesQueryHandler
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
const guestProjectIds = projects
.filter((p) => guestOnly(p, query.viewerId))
.map((p) => p.id);
return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id),
guestProjectIds,
participantId: query.viewerId,
priorities: filter.priorities?.length ? filter.priorities : undefined,
typesByProject: bugTypes,
dueBefore: filter.overdue

View File

@@ -76,7 +76,7 @@ export class GetSprintsQueryHandler
) {}
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findSprints.findByProject(query.projectId);
}
}