mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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>
270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
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);
|
|
});
|
|
});
|