mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(projects): shared authorization lib for Ara Projects (#408)
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) <noreply@anthropic.com>
This commit is contained in:
parent
a0680b0eea
commit
f358b874df
3 changed files with 598 additions and 0 deletions
270
src/lib/projects/authz.test.ts
Normal file
270
src/lib/projects/authz.test.ts
Normal file
|
|
@ -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> = {}): AuthzUser {
|
||||
return {
|
||||
id: 1n,
|
||||
email: "someone@landlord.example",
|
||||
organisationIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<ProjectAuthzFacts> = {}): ProjectAuthzFacts {
|
||||
return {
|
||||
id: 100n,
|
||||
organisationId: CLIENT_ORG,
|
||||
domnaAdminAccess: true,
|
||||
contractorOrganisationIds: [CONTRACTOR_ORG, OTHER_CONTRACTOR_ORG],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkOrder(
|
||||
overrides: Partial<WorkOrderAuthzFacts> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
180
src/lib/projects/authz.ts
Normal file
180
src/lib/projects/authz.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
148
src/lib/projects/authzQueries.ts
Normal file
148
src/lib/projects/authzQueries.ts
Normal file
|
|
@ -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<string[]> {
|
||||
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<AuthzUser | null> {
|
||||
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<ProjectAuthzFacts | null> {
|
||||
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<WorkOrderAuthzFacts | null> {
|
||||
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<bigint[]> {
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue