Files
task-stream/libs/api/tracker/application/src/lib/queries/read-access.ts
Josep Piguillem 8bf7d47b2e 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>
2026-10-15 18:02:36 +02:00

140 lines
4.3 KiB
TypeScript

import { Injectable } from '@nestjs/common';
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';
/**
* Authorisation for the read side, based on the projected memberships:
* members read their projects; anyone (also anonymous, `viewerId` null)
* reads public ones.
*/
@Injectable()
export class ReadAccess {
constructor(private readonly findProjects: FindProjectsRepository) {}
/**
* The viewer's role, or null when they read a public project as an outsider.
* `asked` is what the caller looked for: the error names it, never the
* project behind it (that would confirm a private project exists).
*/
async assertCanView(
projectId: string,
viewerId: string | null,
asked: { entity: string; ref: string } = {
entity: 'Project',
ref: projectId,
},
): Promise<ProjectRole | null> {
const role = viewerId
? await this.findProjects.findRole(projectId, viewerId)
: null;
if (role) return role;
const project = await this.findProjects.findById(projectId);
// Same answer for "doesn't exist" and "not yours": don't leak projects.
if (!project?.isPublic) throw new NotFoundError(asked.entity, asked.ref);
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);
return true;
} catch (error) {
if (error instanceof NotFoundError) return false;
throw error;
}
}
assertFound<T>(value: T | null, entity: string, id: string): T {
if (!value) throw new NotFoundError(entity, id);
return value;
}
}
/** 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;