From f358b874dfe2dfc1d1048f85658cbdbc09d39a7e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 21 Jul 2026 10:52:07 +0000 Subject: [PATCH 1/2] feat(projects): shared authorization lib for Ara Projects (#408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/lib/projects/authz.ts as the single source of permission truth for every Ara Projects route handler — no route re-implements this inline. Ara Projects are organisation-scoped, so none of the portfolio-scoped team_portfolio_permissions machinery applies. Membership resolves through team_members -> team -> org_id instead. Role resolution is most-privileged-first: internal (@domna.homes) > client (member of the owning org) > contractor (member of an org assigned to any of the project's workstreams) > no access. project.domna_admin_access gates the internal role only — with it off, a Domna user falls through to whatever their own memberships earn them rather than losing access outright. Client outranks contractor when one org is both owner and deliverer. The two work-order guards check the permission flag on the *assignment* rather than on the organisation: a contractor needs membership of that work order's own assigned org plus the relevant flag, so holding a permissive assignment on a sibling workstream grants nothing. Structure follows the repo's model/queries split — authz.ts is pure and imports no database client, so all 34 guard tests run on in-memory fixtures with no connection. DB-backed fact loaders (getUserOrganisations and friends) live in authzQueries.ts. Note the guard signatures take the project facts explicitly — canUpdateStage(user, project, workOrder) rather than the (user, workOrder) in the ticket — since the role can only be resolved against the project. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/projects/authz.test.ts | 270 +++++++++++++++++++++++++++++++ src/lib/projects/authz.ts | 180 +++++++++++++++++++++ src/lib/projects/authzQueries.ts | 148 +++++++++++++++++ 3 files changed, 598 insertions(+) create mode 100644 src/lib/projects/authz.test.ts create mode 100644 src/lib/projects/authz.ts create mode 100644 src/lib/projects/authzQueries.ts diff --git a/src/lib/projects/authz.test.ts b/src/lib/projects/authz.test.ts new file mode 100644 index 00000000..11681c59 --- /dev/null +++ b/src/lib/projects/authz.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { + canManageProject, + canUpdateStage, + canUploadEvidence, + canViewProject, + isInternalEmail, + resolveProjectRole, + type AuthzUser, + type ProjectAuthzFacts, + type WorkOrderAuthzFacts, +} from "./authz"; + +const CLIENT_ORG = "11111111-1111-1111-1111-111111111111"; +const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222"; +const OTHER_CONTRACTOR_ORG = "33333333-3333-3333-3333-333333333333"; +const UNRELATED_ORG = "44444444-4444-4444-4444-444444444444"; + +function makeUser(overrides: Partial = {}): AuthzUser { + return { + id: 1n, + email: "someone@landlord.example", + organisationIds: [], + ...overrides, + }; +} + +function makeProject(overrides: Partial = {}): ProjectAuthzFacts { + return { + id: 100n, + organisationId: CLIENT_ORG, + domnaAdminAccess: true, + contractorOrganisationIds: [CONTRACTOR_ORG, OTHER_CONTRACTOR_ORG], + ...overrides, + }; +} + +function makeWorkOrder( + overrides: Partial = {}, +): WorkOrderAuthzFacts { + return { + id: 500n, + contractorOrganisationId: CONTRACTOR_ORG, + updateStagesPermission: false, + uploadDocumentsPermission: false, + ...overrides, + }; +} + +const internal = makeUser({ email: "dev@domna.homes" }); +const client = makeUser({ organisationIds: [CLIENT_ORG] }); +const contractor = makeUser({ organisationIds: [CONTRACTOR_ORG] }); +const stranger = makeUser({ organisationIds: [UNRELATED_ORG] }); + +describe("isInternalEmail", () => { + it("recognises the Domna domain regardless of case or surrounding whitespace", () => { + expect(isInternalEmail("dev@domna.homes")).toBe(true); + expect(isInternalEmail(" Dev@Domna.Homes ")).toBe(true); + }); + + it("does not treat a lookalike domain as internal", () => { + expect(isInternalEmail("attacker@evil-domna.homes")).toBe(false); + expect(isInternalEmail("attacker@domna.homes.evil.com")).toBe(false); + expect(isInternalEmail("someone@landlord.example")).toBe(false); + }); +}); + +describe("resolveProjectRole", () => { + it("resolves a Domna user as internal when the project allows admin access", () => { + expect(resolveProjectRole(internal, makeProject())).toBe("internal"); + }); + + it("resolves a member of the owning organisation as client", () => { + expect(resolveProjectRole(client, makeProject())).toBe("client"); + }); + + it("resolves a member of an assigned contractor organisation as contractor", () => { + expect(resolveProjectRole(contractor, makeProject())).toBe("contractor"); + }); + + it("resolves a member of any assigned contractor org, not just the first", () => { + const user = makeUser({ organisationIds: [OTHER_CONTRACTOR_ORG] }); + expect(resolveProjectRole(user, makeProject())).toBe("contractor"); + }); + + it("gives no role to a user with no relevant membership", () => { + expect(resolveProjectRole(stranger, makeProject())).toBeNull(); + }); + + it("gives no role on a project with no contractors assigned yet", () => { + const project = makeProject({ contractorOrganisationIds: [] }); + expect(resolveProjectRole(contractor, project)).toBeNull(); + }); + + it("ranks client above contractor when one org is both owner and contractor", () => { + const project = makeProject({ contractorOrganisationIds: [CLIENT_ORG] }); + expect(resolveProjectRole(client, project)).toBe("client"); + }); + + describe("domna_admin_access = false", () => { + const locked = makeProject({ domnaAdminAccess: false }); + + it("denies a Domna user any role when they have no membership of their own", () => { + expect(resolveProjectRole(internal, locked)).toBeNull(); + }); + + it("still resolves a Domna user as client when they belong to the owning org", () => { + const domnaClient = makeUser({ + email: "dev@domna.homes", + organisationIds: [CLIENT_ORG], + }); + expect(resolveProjectRole(domnaClient, locked)).toBe("client"); + }); + + it("still resolves a Domna user as contractor when they belong to an assigned org", () => { + const domnaContractor = makeUser({ + email: "dev@domna.homes", + organisationIds: [CONTRACTOR_ORG], + }); + expect(resolveProjectRole(domnaContractor, locked)).toBe("contractor"); + }); + + it("does not affect a non-Domna client", () => { + expect(resolveProjectRole(client, locked)).toBe("client"); + }); + }); +}); + +describe("canViewProject", () => { + it("admits every resolved role", () => { + const project = makeProject(); + expect(canViewProject(internal, project)).toBe(true); + expect(canViewProject(client, project)).toBe(true); + expect(canViewProject(contractor, project)).toBe(true); + }); + + it("refuses a user with no role", () => { + expect(canViewProject(stranger, makeProject())).toBe(false); + }); + + it("refuses a Domna user on a project with admin access switched off", () => { + expect(canViewProject(internal, makeProject({ domnaAdminAccess: false }))).toBe( + false, + ); + }); +}); + +describe("canManageProject", () => { + it("admits internal and client", () => { + const project = makeProject(); + expect(canManageProject(internal, project)).toBe(true); + expect(canManageProject(client, project)).toBe(true); + }); + + it("refuses a contractor even though they can view the project", () => { + const project = makeProject(); + expect(canViewProject(contractor, project)).toBe(true); + expect(canManageProject(contractor, project)).toBe(false); + }); + + it("refuses a user with no role", () => { + expect(canManageProject(stranger, makeProject())).toBe(false); + }); + + it("refuses a Domna user on a project with admin access switched off", () => { + expect( + canManageProject(internal, makeProject({ domnaAdminAccess: false })), + ).toBe(false); + }); +}); + +describe("canUpdateStage", () => { + const project = makeProject(); + + it("admits internal and client regardless of the assignment's flags", () => { + const workOrder = makeWorkOrder({ updateStagesPermission: false }); + expect(canUpdateStage(internal, project, workOrder)).toBe(true); + expect(canUpdateStage(client, project, workOrder)).toBe(true); + }); + + it("admits a contractor holding the assignment and the permission", () => { + const workOrder = makeWorkOrder({ updateStagesPermission: true }); + expect(canUpdateStage(contractor, project, workOrder)).toBe(true); + }); + + it("refuses a contractor whose assignment lacks update_stages_permission", () => { + const workOrder = makeWorkOrder({ updateStagesPermission: false }); + expect(canUpdateStage(contractor, project, workOrder)).toBe(false); + }); + + it("refuses a contractor from a different assigned org, even with the flag set", () => { + const other = makeUser({ organisationIds: [OTHER_CONTRACTOR_ORG] }); + const workOrder = makeWorkOrder({ + contractorOrganisationId: CONTRACTOR_ORG, + updateStagesPermission: true, + }); + expect(resolveProjectRole(other, project)).toBe("contractor"); + expect(canUpdateStage(other, project, workOrder)).toBe(false); + }); + + it("refuses a user with no role on the project", () => { + const workOrder = makeWorkOrder({ updateStagesPermission: true }); + expect(canUpdateStage(stranger, project, workOrder)).toBe(false); + }); + + it("refuses a Domna user when admin access is off and they have no membership", () => { + const locked = makeProject({ domnaAdminAccess: false }); + const workOrder = makeWorkOrder({ updateStagesPermission: true }); + expect(canUpdateStage(internal, locked, workOrder)).toBe(false); + }); + + it("ignores upload_documents_permission", () => { + const workOrder = makeWorkOrder({ + updateStagesPermission: false, + uploadDocumentsPermission: true, + }); + expect(canUpdateStage(contractor, project, workOrder)).toBe(false); + }); +}); + +describe("canUploadEvidence", () => { + const project = makeProject(); + + it("admits internal and client regardless of the assignment's flags", () => { + const workOrder = makeWorkOrder({ uploadDocumentsPermission: false }); + expect(canUploadEvidence(internal, project, workOrder)).toBe(true); + expect(canUploadEvidence(client, project, workOrder)).toBe(true); + }); + + it("admits a contractor holding the assignment and the permission", () => { + const workOrder = makeWorkOrder({ uploadDocumentsPermission: true }); + expect(canUploadEvidence(contractor, project, workOrder)).toBe(true); + }); + + it("refuses a contractor whose assignment lacks upload_documents_permission", () => { + const workOrder = makeWorkOrder({ uploadDocumentsPermission: false }); + expect(canUploadEvidence(contractor, project, workOrder)).toBe(false); + }); + + it("refuses a contractor from a different assigned org, even with the flag set", () => { + const other = makeUser({ organisationIds: [OTHER_CONTRACTOR_ORG] }); + const workOrder = makeWorkOrder({ + contractorOrganisationId: CONTRACTOR_ORG, + uploadDocumentsPermission: true, + }); + expect(canUploadEvidence(other, project, workOrder)).toBe(false); + }); + + it("refuses a user with no role on the project", () => { + const workOrder = makeWorkOrder({ uploadDocumentsPermission: true }); + expect(canUploadEvidence(stranger, project, workOrder)).toBe(false); + }); + + it("ignores update_stages_permission", () => { + const workOrder = makeWorkOrder({ + uploadDocumentsPermission: false, + updateStagesPermission: true, + }); + expect(canUploadEvidence(contractor, project, workOrder)).toBe(false); + }); + + it("is independent of canUpdateStage — the two flags grant separately", () => { + const uploadOnly = makeWorkOrder({ + updateStagesPermission: false, + uploadDocumentsPermission: true, + }); + expect(canUpdateStage(contractor, project, uploadOnly)).toBe(false); + expect(canUploadEvidence(contractor, project, uploadOnly)).toBe(true); + }); +}); diff --git a/src/lib/projects/authz.ts b/src/lib/projects/authz.ts new file mode 100644 index 00000000..a65b0588 --- /dev/null +++ b/src/lib/projects/authz.ts @@ -0,0 +1,180 @@ +/** + * Ara Projects authorization — the single source of permission truth for every + * Ara Projects route handler (issue #408). No route may re-implement any of + * this inline. + * + * This module is deliberately **pure**: it imports no database client, so the + * guards unit-test without mocking a connection pool. The DB-backed resolvers + * that produce its inputs live next door in `./authzQueries` — a route handler + * loads facts there, then decides here. + * + * const [user, project] = await Promise.all([ + * loadAuthzUser(userId), + * loadProjectAuthzFacts(projectId), + * ]); + * if (!canViewProject(user, project)) return forbidden(); + * + * Scoping note: Ara Projects are **organisation**-scoped. The existing + * `team_portfolio_permissions` machinery is portfolio-scoped and plays no part + * here — do not reach for it. A user's organisations come from + * `team_members → team → org_id` (see CONTEXT.md, "Ara Projects"). + */ + +/** The email domain that identifies a Domna (internal) user. */ +export const INTERNAL_EMAIL_DOMAIN = "@domna.homes"; + +/** + * A user's standing on one particular Project. + * + * - `internal` — a Domna user, on a project that opts into admin access. + * - `client` — a member of the organisation that owns the project. + * - `contractor` — a member of an organisation assigned to one of the + * project's workstreams. + * + * Absence of a role (`null`) means no access at all, not read-only access. + */ +export type ProjectRole = "internal" | "client" | "contractor"; + +/** The requesting user, with their organisation memberships already resolved. */ +export interface AuthzUser { + id: bigint; + email: string; + /** `organisation.id` values (uuid) reached via team_members → team → org_id. */ + organisationIds: string[]; +} + +/** The permission-relevant facts about one Project. */ +export interface ProjectAuthzFacts { + id: bigint; + /** `project.organisation_id` — the client organisation that owns the project. */ + organisationId: string; + /** `project.domna_admin_access` — see `resolveProjectRole`. */ + domnaAdminAccess: boolean; + /** + * Every organisation assigned as contractor to any of this project's + * workstreams, via `project_workstream_contractor`. + */ + contractorOrganisationIds: string[]; +} + +/** + * The permission-relevant facts about one Work order, flattened from its + * `project_workstream_contractor` assignment. The permission flags belong to + * the *assignment*, not to the organisation globally — the same contractor org + * may hold different permissions on two workstreams of the same project. + */ +export interface WorkOrderAuthzFacts { + id: bigint; + /** `project_workstream_contractor.organisation_id` for this work order. */ + contractorOrganisationId: string; + updateStagesPermission: boolean; + uploadDocumentsPermission: boolean; +} + +/** True when the email sits on the Domna domain (case- and whitespace-tolerant). */ +export function isInternalEmail(email: string): boolean { + return email.trim().toLowerCase().endsWith(INTERNAL_EMAIL_DOMAIN); +} + +/** + * Resolve what a user is to a project, most-privileged first. + * + * `project.domna_admin_access` gates the `internal` role only: when it is + * false, a Domna user gets no elevated standing on that project, but still + * falls through to whatever their organisation memberships earn them — a Domna + * user who is genuinely a member of the client org remains a `client`. + * + * Client outranks contractor: an organisation that both owns the project and + * delivers one of its workstreams is treated as the client. + */ +export function resolveProjectRole( + user: AuthzUser, + project: ProjectAuthzFacts, +): ProjectRole | null { + if (project.domnaAdminAccess && isInternalEmail(user.email)) return "internal"; + + const memberships = new Set(user.organisationIds); + if (memberships.has(project.organisationId)) return "client"; + if (project.contractorOrganisationIds.some((orgId) => memberships.has(orgId))) { + return "contractor"; + } + return null; +} + +/** Any resolved role may read the project. */ +export function canViewProject( + user: AuthzUser, + project: ProjectAuthzFacts, +): boolean { + return resolveProjectRole(user, project) !== null; +} + +/** + * Administering the project — editing it, configuring workstreams, assigning + * contractors. Internal and client only; a contractor never manages. + */ +export function canManageProject( + user: AuthzUser, + project: ProjectAuthzFacts, +): boolean { + const role = resolveProjectRole(user, project); + return role === "internal" || role === "client"; +} + +/** + * Move a Work order along its stage ladder. + * + * Internal and client may always do so. A contractor may only when the work + * order is issued to an organisation they belong to *and* that assignment + * carries `update_stages_permission` — belonging to some *other* assigned org + * on the same project is not enough. + */ +export function canUpdateStage( + user: AuthzUser, + project: ProjectAuthzFacts, + workOrder: WorkOrderAuthzFacts, +): boolean { + return allowsWorkOrderAction( + user, + project, + workOrder, + workOrder.updateStagesPermission, + ); +} + +/** + * Upload Evidence against a Work order. Same shape as `canUpdateStage`, gated + * on the assignment's `upload_documents_permission`. + */ +export function canUploadEvidence( + user: AuthzUser, + project: ProjectAuthzFacts, + workOrder: WorkOrderAuthzFacts, +): boolean { + return allowsWorkOrderAction( + user, + project, + workOrder, + workOrder.uploadDocumentsPermission, + ); +} + +/** + * The shared shape of the two work-order guards: privileged roles pass + * unconditionally, a contractor needs both the assignment and its flag, and + * anyone without a role is refused. + */ +function allowsWorkOrderAction( + user: AuthzUser, + project: ProjectAuthzFacts, + workOrder: WorkOrderAuthzFacts, + permissionFlag: boolean, +): boolean { + const role = resolveProjectRole(user, project); + if (role === "internal" || role === "client") return true; + if (role !== "contractor") return false; + return ( + user.organisationIds.includes(workOrder.contractorOrganisationId) && + permissionFlag + ); +} diff --git a/src/lib/projects/authzQueries.ts b/src/lib/projects/authzQueries.ts new file mode 100644 index 00000000..fea89557 --- /dev/null +++ b/src/lib/projects/authzQueries.ts @@ -0,0 +1,148 @@ +/** + * DB-backed fact loaders feeding the pure guards in `./authz` (issue #408). + * + * Kept separate from the guards so those stay unit-testable without a database. + * Route handlers load facts here, then decide with `authz`. + * + * Every loader is a single round trip; none probes per-row inside a list. + */ +import { db } from "@/app/db/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { team, teamMembers } from "@/app/db/schema/team"; +import { user } from "@/app/db/schema/users"; +import { + project, + projectWorkstream, + projectWorkstreamContractor, + workOrder, +} from "@/app/db/schema/projects/projects"; +import type { + AuthzUser, + ProjectAuthzFacts, + WorkOrderAuthzFacts, +} from "./authz"; + +/** + * The organisations a user belongs to, via `team_members → team → org_id`. + * + * This is the *only* membership path Ara Projects recognises. Each + * organisation has a default team, so contractor and client users alike arrive + * through the existing team structures. Deduplicated: a user on two teams of + * one organisation yields that organisation once. + */ +export async function getUserOrganisations(userId: bigint): Promise { + const rows = await db + .selectDistinct({ orgId: team.orgId }) + .from(teamMembers) + .innerJoin(team, eq(teamMembers.teamId, team.id)) + .where(eq(teamMembers.userId, userId)); + + return rows.map((r) => r.orgId); +} + +/** + * Load a user plus their organisation memberships, ready to hand to a guard. + * Returns null when the user does not exist. + */ +export async function loadAuthzUser(userId: bigint): Promise { + const [[found], organisationIds] = await Promise.all([ + db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1), + getUserOrganisations(userId), + ]); + + if (!found) return null; + return { id: found.id, email: found.email, organisationIds }; +} + +/** + * Load one project's permission-relevant facts, including every organisation + * assigned as contractor to any of its workstreams. Returns null when the + * project does not exist — callers should treat that as a 404, not a 403. + */ +export async function loadProjectAuthzFacts( + projectId: bigint, +): Promise { + const [[found], contractorRows] = await Promise.all([ + db + .select({ + id: project.id, + organisationId: project.organisationId, + domnaAdminAccess: project.domnaAdminAccess, + }) + .from(project) + .where(eq(project.id, projectId)) + .limit(1), + db + .selectDistinct({ organisationId: projectWorkstreamContractor.organisationId }) + .from(projectWorkstreamContractor) + .innerJoin( + projectWorkstream, + eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id), + ) + .where(eq(projectWorkstream.projectId, projectId)), + ]); + + if (!found) return null; + return { + ...found, + contractorOrganisationIds: contractorRows.map((r) => r.organisationId), + }; +} + +/** + * Load one work order's contractor assignment facts. Returns null when the + * work order does not exist. + * + * `projectId` narrows the lookup to the project named in the request path, so + * a work-order id belonging to another project cannot be authorized against + * the facts of the project the caller happens to have access to. + */ +export async function loadWorkOrderAuthzFacts( + workOrderId: bigint, + projectId: bigint, +): Promise { + const [found] = await db + .select({ + id: workOrder.id, + contractorOrganisationId: projectWorkstreamContractor.organisationId, + updateStagesPermission: projectWorkstreamContractor.updateStagesPermission, + uploadDocumentsPermission: + projectWorkstreamContractor.uploadDocumentsPermission, + }) + .from(workOrder) + .innerJoin( + projectWorkstreamContractor, + eq(workOrder.projectWorkstreamContractorId, projectWorkstreamContractor.id), + ) + .innerJoin( + projectWorkstream, + eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id), + ) + .where( + and(eq(workOrder.id, workOrderId), eq(projectWorkstream.projectId, projectId)), + ) + .limit(1); + + return found ?? null; +} + +/** + * The projects a set of organisations own, for list endpoints that must not + * leak projects the caller cannot see. Contractor visibility is resolved + * separately (a contractor sees a project through its workstream assignments), + * so this covers the client side only. + */ +export async function listProjectIdsForOrganisations( + organisationIds: string[], +): Promise { + if (organisationIds.length === 0) return []; + const rows = await db + .select({ id: project.id }) + .from(project) + .where(inArray(project.organisationId, organisationIds)); + return rows.map((r) => r.id); +} From 1ce8a65faf2eb18a49816f98e81922534c86c4df Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 21 Jul 2026 16:12:26 +0000 Subject: [PATCH 2/2] refactor(projects): move authz persistence into a repositories layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganises the authz lib along DDD lines by separating the persistence boundary from the domain: src/lib/projects/authz.ts domain — pure decisions src/app/repositories/projects/authzRepository.ts persistence — Drizzle The repository sits alongside src/app/db/ and mirrors the existing db/schema/projects/ layout, so schema and repository stay symmetrical as Ara Projects grows more of both. Dependencies point one way: the repository imports the domain's fact types and returns them, so the domain never learns a table shape and continues to import no database client. The 34 guard tests still run with no connection. No behaviour change — the queries and guards are untouched. Note this is a new pattern for the codebase: the prevailing convention is a colocated queries.ts/server.ts per lib folder, which 88 files still follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../repositories/projects/authzRepository.ts} | 17 +++++++++++++---- src/lib/projects/authz.ts | 16 +++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) rename src/{lib/projects/authzQueries.ts => app/repositories/projects/authzRepository.ts} (87%) diff --git a/src/lib/projects/authzQueries.ts b/src/app/repositories/projects/authzRepository.ts similarity index 87% rename from src/lib/projects/authzQueries.ts rename to src/app/repositories/projects/authzRepository.ts index fea89557..418f6e55 100644 --- a/src/lib/projects/authzQueries.ts +++ b/src/app/repositories/projects/authzRepository.ts @@ -1,8 +1,17 @@ /** - * DB-backed fact loaders feeding the pure guards in `./authz` (issue #408). + * Ara Projects authorization repository (issue #408). * - * Kept separate from the guards so those stay unit-testable without a database. - * Route handlers load facts here, then decide with `authz`. + * The persistence boundary for authorization: it owns the Drizzle queries and + * returns domain-shaped facts (`ProjectAuthzFacts`, `WorkOrderAuthzFacts`) + * rather than raw rows, so the domain layer never sees a table shape. The + * decisions themselves live in `@/lib/projects/authz`, which imports no + * database client and unit-tests without a connection. + * + * const [user, project] = await Promise.all([ + * loadAuthzUser(userId), + * loadProjectAuthzFacts(projectId), + * ]); + * if (!canViewProject(user, project)) return forbidden(); * * Every loader is a single round trip; none probes per-row inside a list. */ @@ -20,7 +29,7 @@ import type { AuthzUser, ProjectAuthzFacts, WorkOrderAuthzFacts, -} from "./authz"; +} from "@/lib/projects/authz"; /** * The organisations a user belongs to, via `team_members → team → org_id`. diff --git a/src/lib/projects/authz.ts b/src/lib/projects/authz.ts index a65b0588..7fbd15c4 100644 --- a/src/lib/projects/authz.ts +++ b/src/lib/projects/authz.ts @@ -3,16 +3,14 @@ * Ara Projects route handler (issue #408). No route may re-implement any of * this inline. * - * This module is deliberately **pure**: it imports no database client, so the - * guards unit-test without mocking a connection pool. The DB-backed resolvers - * that produce its inputs live next door in `./authzQueries` — a route handler - * loads facts there, then decides here. + * This is the domain layer: deliberately **pure**, importing no database + * client, so the guards unit-test without mocking a connection pool. It owns + * the vocabulary (`ProjectRole`, `ProjectAuthzFacts`) and the decisions. * - * const [user, project] = await Promise.all([ - * loadAuthzUser(userId), - * loadProjectAuthzFacts(projectId), - * ]); - * if (!canViewProject(user, project)) return forbidden(); + * Persistence lives behind the repository at + * `@/app/repositories/projects/authzRepository`, which loads those facts. A + * route handler loads there, then decides here — and the dependency only ever + * points that way, so the domain never learns a table shape. * * Scoping note: Ara Projects are **organisation**-scoped. The existing * `team_portfolio_permissions` machinery is portfolio-scoped and plays no part