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 { 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 { 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 { 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 { 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 { 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 { try { await this.assertCanView(projectId, viewerId); return true; } catch (error) { if (error instanceof NotFoundError) return false; throw error; } } assertFound(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;