Lock Down TaskFlow Permissions
Build Bun tests and enforce owner-only workspace updates in TaskFlow.
Introduction
30 Second Summary
An app can feel reliable until the wrong teammate changes settings they should never control. Scattered permission rules make that mistake hard to spot before it reaches real users.
In this project, you will turn TaskFlow's scattered permissions into a default-deny authorization policy backed by Bun tests. You will use that policy to stop workspace admins from changing owner-controlled settings.
What You'll Build
You'll demo a passing TaskFlow permission suite that blocks a workspace admin with an HTTP 403 response while the owner updates the same settings successfully.
By the end of this project, you'll have:
- An executable default-deny permission policy with a fast regression suite covering TaskFlow's complete role hierarchy.
- A hardened workspace update flow that rejects admins with HTTP 403 while preserving owner access.
- A reviewable permission baseline that future pull requests can check against the written role matrix plus coverage report.
- Secret Mission: Add table-driven regression cases proving obsolete or unexpected project roles never inherit privileges.
Are there any prerequisites?
You need the existing TaskFlow stack working locally on Linux. Its backend environment must already have working PostgreSQL, Google OAuth, plus Cloudinary integrations.
Before We Start
Before any hands-on work, commit to the permission boundary you are about to protect. Workspace settings must remain under the workspace owner's control.
Verify the TaskFlow Backend
A permission refactor needs a known-good starting point. The existing TaskFlow backend must still start before its authorization logic changes.
This baseline preserves your configured integrations while you align Bun to 1.4.2. You then prove the current backend still reaches its startup log.
In this step, get ready to:
- Open the existing TaskFlow repository in Visual Studio Code.
- Align the installed Bun runtime with the project baseline.
- Prepare the existing backend for a successful startup check.
Open the repository from the backend folder
Visual Studio Code keeps the existing repository beside its integrated terminal. Starting from backend ensures every Bun command targets the correct package.
- Search for Visual Studio Code in your Linux application launcher.
- Select Visual Studio Code from the search results.
- Select the folder-opening option from the top menu.
- Choose your existing TaskFlow repository folder.
- Find backend in the editor's file tree.
- Use the context menu for backend to start an integrated terminal there.
You'll see the terminal prompt point to the backend directory. The commands that follow now apply to TaskFlow's backend package.
Terminal Started in the Wrong Folder?
- Close the terminal that started outside backend.
- Find backend in the editor's file tree.
- Use its context menu to start a new integrated terminal.
Get help locating the correct TaskFlow directory.
Align Bun and install dependencies
A consistent runtime removes version differences from the permission tests you add later. The existing Bun installation needs to report the project's exact baseline.
- Check the installed Bun version by running this command:
bun --version
What Does This Check?
- The bun --version command prints the active Bun runtime version.
- Version 1.4.2 gives this project a consistent backend baseline.
✔️ The Version Matches
You're aligned. Bun 1.4.2 is ready to run the existing TaskFlow backend.
ⓧ The Version Differs
The installed Bun version needs alignment before you test the backend.
- Upgrade the existing Bun installation by running this command:
bun upgrade
What Does This Command Do?
The bun upgrade command updates the existing Bun installation. A fresh terminal loads the updated executable.
- Close the current integrated terminal after the upgrade finishes.
- Start a fresh integrated terminal from the backend context menu.
- Check the updated Bun version by running this command:
bun --version
What Does the Second Check Prove?
The fresh terminal resolves the updated Bun executable. Its output confirms whether the project environment now uses the expected version.
You should now see 1.4.2 in the terminal.
Still Seeing a Different Version?
- Confirm the upgrade finished before you closed the previous terminal.
- Close every integrated terminal before starting another from backend.
- Run the version check again in the fresh terminal.
Get help aligning the active Bun installation.
The runtime is ready. The backend still needs its existing packages installed before the startup check.
- Install the existing backend dependencies by running this command:
bun install
What Does This Command Do?
The bun install command reads the backend package metadata. It synchronizes the packages required by the existing TaskFlow code.
- Wait for the dependency installation to finish.
You'll see the terminal prompt return without an installation error. The backend dependencies are now ready for the startup check.
Dependency Installation Failed?
- Confirm the integrated terminal still points to backend.
- Check that your Linux machine has an active network connection.
- Retry the installation after restoring the connection.
Get help diagnosing the failed dependency installation.
Start and stop the existing backend
The final baseline check runs the backend with its existing environment configuration. A successful startup proves the runtime still reaches the server entry point before the permission work begins.
Before you run this, predict whether the unchanged backend reaches its startup log.
- Start the watched TaskFlow backend by running this command:
bun run dev
What Does This Command Do?
- The bun run dev command launches TaskFlow's watched backend server.
- The process keeps the terminal occupied until you stop the server.
You'll see Server is running on http://localhost:8000 in the startup log. This confirms the existing backend can still start with its configured environment.
Backend Did Not Reach the Startup Log?
- Confirm the dependency installation completed without an error.
- Confirm the existing backend environment configuration is still available.
- Confirm the local database service is running.
Get help troubleshooting the backend startup.
- Stop the verified backend by pressing Ctrl+C in the integrated terminal.
That's the baseline secured. The terminal prompt returns after TaskFlow proves it can start on Bun 1.4.2.
Your existing backend is ready for a controlled permission change. Next, you'll turn the intended role hierarchy into an executable test contract.
Expose the Permission Contract Gap
Your existing TaskFlow backend runs cleanly on Bun 1.4.2.
TaskFlow's permission rules currently live inside long service functions. In this step, you will turn the agreed hierarchy into executable tests so the missing centralized contract becomes visible.
In this step, get ready to:
- Create the permission policy test file.
- Read the tests as an executable permission hierarchy.
- Run the focused test to inspect the current contract gap.
Create the permission test file
A regression test records the behavior that must remain true as TaskFlow changes. These tests describe the permission rules before a centralized policy implements them.
- In the Visual Studio Code Explorer sidebar, right-click the src folder.
- Select New Folder.
- Type policies as the folder name.
- Press Enter.
- Right-click the new policies folder.
- Select New File.
- Type permission.policy.test.ts as the file name.
- Press Enter.
- Paste the imports and shared ownership contexts into permission.policy.test.ts:
import { expect, test } from "bun:test";
import {
canArchiveOrganization,
canArchiveProject,
canArchiveWorkspace,
canManageBoard,
canManageOrganizationMembers,
canManageProjectMembers,
canManageWorkspaceMembers,
canUpdateOrganization,
canUpdateProject,
canUpdateWorkspace,
} from "./permission.policy";
const ownerContext = {
actorId: "user-owner",
ownerId: "user-owner",
};
const nonOwnerContext = {
actorId: "user-admin",
ownerId: "user-owner",
};
How Does This Set Up the Contract?
- The bun:test import supplies the test function and its assertions.
- The policy import names every permission that the contract expects.
- The ownerContext object represents an actor who owns the parent resource.
- The nonOwnerContext object represents a different actor.
- Save permission.policy.test.ts.
- Confirm the editor shows both context objects below the import block.
Imports or Contexts Look Different?
- Check that the file sits inside src/policies.
- Check that both import paths match the code exactly.
- Check that each context contains matching actorId and ownerId keys.
Help me check my test setup.
- Add the organization and workspace permission cases below nonOwnerContext:
test("only the organization owner can update or archive the organization", () => {
expect(canUpdateOrganization(ownerContext)).toBe(true);
expect(canArchiveOrganization(ownerContext)).toBe(true);
expect(canUpdateOrganization(nonOwnerContext)).toBe(false);
expect(canArchiveOrganization(nonOwnerContext)).toBe(false);
});
test("organization owners and admins can manage members", () => {
expect(canManageOrganizationMembers("OWNER")).toBe(true);
expect(canManageOrganizationMembers("ADMIN")).toBe(true);
expect(canManageOrganizationMembers("MEMBER")).toBe(false);
});
test("only the workspace owner can update or archive the workspace", () => {
expect(canUpdateWorkspace(ownerContext)).toBe(true);
expect(canArchiveWorkspace(ownerContext)).toBe(true);
expect(canUpdateWorkspace(nonOwnerContext)).toBe(false);
expect(canArchiveWorkspace(nonOwnerContext)).toBe(false);
});
test("workspace owners and admins can manage members", () => {
expect(canManageWorkspaceMembers("OWNER")).toBe(true);
expect(canManageWorkspaceMembers("ADMIN")).toBe(true);
expect(canManageWorkspaceMembers("MEMBER")).toBe(false);
expect(canManageWorkspaceMembers("GUEST")).toBe(false);
});
What Do These Tests Protect?
- Organization settings remain under direct owner control.
- Organization admins retain member-management permission.
- Workspace settings remain under direct owner control.
- Workspace admins retain member-management permission.
- Save permission.policy.test.ts.
- Confirm the editor shows four complete tests below the ownership contexts.
Seeing TypeScript Syntax Problems?
- Check that every test() call ends with });.
- Check that every role value keeps its uppercase spelling.
- Check that each assertion uses either true or false.
Help me find the syntax mismatch.
- Add the project permission cases below the workspace tests:
test("project admins can manage settings, members, and boards", () => {
expect(canUpdateProject("ADMIN")).toBe(true);
expect(canManageProjectMembers("ADMIN")).toBe(true);
expect(canManageBoard("ADMIN")).toBe(true);
expect(canUpdateProject("MEMBER")).toBe(false);
expect(canManageProjectMembers("MEMBER")).toBe(false);
expect(canManageBoard("MEMBER")).toBe(false);
});
test("project admins cannot archive a project unless they own its workspace", () => {
expect(canArchiveProject(ownerContext)).toBe(true);
expect(canArchiveProject(nonOwnerContext)).toBe(false);
});
How Do Project Permissions Differ?
- A project ADMIN can update project settings.
- A project ADMIN can manage project members.
- A project ADMIN can manage the board.
- Project archival depends on ownership of the parent workspace.
- Save permission.policy.test.ts.
- Confirm the file ends with the project archival test.
Missing a Project Assertion?
- Check that each project-management function has one ADMIN assertion.
- Check that each project-management function has one MEMBER assertion.
- Check that the archival test uses both ownership contexts.
Help me check the project permission cases.
✔️ Awesome, I've got everything!
Your test file now captures the complete organization, workspace, and project permission hierarchy.
ⓧ I'd like to double check the full code
import { expect, test } from "bun:test";
import {
canArchiveOrganization,
canArchiveProject,
canArchiveWorkspace,
canManageBoard,
canManageOrganizationMembers,
canManageProjectMembers,
canManageWorkspaceMembers,
canUpdateOrganization,
canUpdateProject,
canUpdateWorkspace,
} from "./permission.policy";
const ownerContext = {
actorId: "user-owner",
ownerId: "user-owner",
};
const nonOwnerContext = {
actorId: "user-admin",
ownerId: "user-owner",
};
test("only the organization owner can update or archive the organization", () => {
expect(canUpdateOrganization(ownerContext)).toBe(true);
expect(canArchiveOrganization(ownerContext)).toBe(true);
expect(canUpdateOrganization(nonOwnerContext)).toBe(false);
expect(canArchiveOrganization(nonOwnerContext)).toBe(false);
});
test("organization owners and admins can manage members", () => {
expect(canManageOrganizationMembers("OWNER")).toBe(true);
expect(canManageOrganizationMembers("ADMIN")).toBe(true);
expect(canManageOrganizationMembers("MEMBER")).toBe(false);
});
test("only the workspace owner can update or archive the workspace", () => {
expect(canUpdateWorkspace(ownerContext)).toBe(true);
expect(canArchiveWorkspace(ownerContext)).toBe(true);
expect(canUpdateWorkspace(nonOwnerContext)).toBe(false);
expect(canArchiveWorkspace(nonOwnerContext)).toBe(false);
});
test("workspace owners and admins can manage members", () => {
expect(canManageWorkspaceMembers("OWNER")).toBe(true);
expect(canManageWorkspaceMembers("ADMIN")).toBe(true);
expect(canManageWorkspaceMembers("MEMBER")).toBe(false);
expect(canManageWorkspaceMembers("GUEST")).toBe(false);
});
test("project admins can manage settings, members, and boards", () => {
expect(canUpdateProject("ADMIN")).toBe(true);
expect(canManageProjectMembers("ADMIN")).toBe(true);
expect(canManageBoard("ADMIN")).toBe(true);
expect(canUpdateProject("MEMBER")).toBe(false);
expect(canManageProjectMembers("MEMBER")).toBe(false);
expect(canManageBoard("MEMBER")).toBe(false);
});
test("project admins cannot archive a project unless they own its workspace", () => {
expect(canArchiveProject(ownerContext)).toBe(true);
expect(canArchiveProject(nonOwnerContext)).toBe(false);
});
Read the executable hierarchy
These tests form an executable role-based access control matrix. Each assertion states which actor receives a specific permission.
- Read the first two tests to identify the organization boundary.
- Read the next two tests to identify the workspace boundary.
- Read the final two tests to identify the project boundary.
What Hierarchy Do the Tests Describe?
Parent owners control settings and archival. Parent admins manage members only.
Project admins manage settings. They also manage members and boards.
Project archival remains tied to ownership of the parent workspace. This keeps the highest-impact mutation behind an ownership check.
Run the contract test
The focused command asks Bun to load only this permission contract. Its result shows whether TaskFlow can execute the policy assertions in their current state.
Before you run it, do you think Bun can execute assertions when their imported module is absent?
- Run the focused contract test from the existing backend terminal by using this command:
bun test ./src/policies/permission.policy.test.ts
The Red Result Is Intentional
Bun stops while resolving ./permission.policy. The six tests cannot run because src/policies/permission.policy.ts does not exist.
That red result is useful. Your test file now exposes the missing centralized contract with a repeatable check.
Seeing a Different Failure?
- Confirm the terminal is still inside the backend directory.
- Confirm the test file is saved at src/policies/permission.policy.test.ts.
- Confirm the import path is exactly ./permission.policy.
Help me diagnose my focused test result.
Your permission hierarchy is now visible as an executable contract. Next, you will create the pure policy module that turns this red test green.
Implement the Permission Policy
Your red Bun test has done its job. It proves the TaskFlow backend has no centralized permission contract.
A pure policy function turns each permission into a small Boolean decision. Default-deny authorization grants access only when an ownership check or role check matches an approved case.
In this step, get ready to:
- Define the ownership context and supported role types.
- Implement the organization, workspace, and project permission checks.
- Confirm the permission contract with the focused Bun test.
Why Pure Policy Functions?
Policy functions receive plain values such as an actor ID or role. They return a permission decision without querying PostgreSQL.
This separation gives every service the same source of truth. It also keeps the regression suite fast.
Define the policy foundation
Owner-only mutations depend on an exact match between actorId and ownerId. Runtime role checks accept unexpected values so unmatched roles naturally receive a denied result.
- In the Visual Studio Code file sidebar, create src/policies/permission.policy.ts beside src/policies/permission.policy.test.ts.
- Add the ownership foundation and organization rules by pasting this code into the new file:
export type OwnershipContext = Readonly<{
actorId: string;
ownerId: string;
}>;
export type OrganizationRole = "OWNER" | "ADMIN" | "MEMBER";
export type WorkspaceRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
export type ProjectRole = "ADMIN" | "MEMBER";
type RuntimeRole = string | null | undefined;
const isOwner = ({ actorId, ownerId }: OwnershipContext) =>
actorId === ownerId;
export const canUpdateOrganization = (context: OwnershipContext) =>
isOwner(context);
export const canArchiveOrganization = (context: OwnershipContext) =>
isOwner(context);
export const canManageOrganizationMembers = (role: RuntimeRole) =>
role === "OWNER" || role === "ADMIN";
What Does This Code Establish?
- The OwnershipContext type pairs the current actor with the resource owner.
- The exported role types record the supported organization, workspace, and project roles.
- The private RuntimeRole type lets permission checks safely receive missing or unexpected runtime values.
- The isOwner() helper gives owner-only organization actions one exact identity check.
- Save src/policies/permission.policy.ts.
- Confirm the file sidebar now lists permission.policy.ts beside permission.policy.test.ts.
Missing the New Policy File?
- Check that permission.policy.ts sits inside the same src/policies folder as the test file.
- Confirm the filename ends with .ts.
Help me check why my TaskFlow policy file is missing from Visual Studio Code.
Cover workspace and project decisions
Workspace settings use the same owner identity check as organization settings. Project management permissions require an explicit ADMIN role.
- Place your cursor after the canManageOrganizationMembers function in src/policies/permission.policy.ts.
- Add the workspace and project decisions by pasting this code below the existing function:
export const canUpdateWorkspace = (context: OwnershipContext) =>
isOwner(context);
export const canArchiveWorkspace = (context: OwnershipContext) =>
isOwner(context);
export const canManageWorkspaceMembers = (role: RuntimeRole) =>
role === "OWNER" || role === "ADMIN";
export const canUpdateProject = (role: RuntimeRole) =>
role === "ADMIN";
export const canManageProjectMembers = (role: RuntimeRole) =>
role === "ADMIN";
export const canManageBoard = (role: RuntimeRole) =>
role === "ADMIN";
export const canArchiveProject = (context: OwnershipContext) =>
isOwner(context);
How Do These Checks Enforce the Contract?
- Workspace updates and archival delegate to isOwner().
- Workspace member management accepts the OWNER role or the ADMIN role.
- Project updates, member management, and board management accept only the ADMIN role.
- Project archival depends on ownership of the parent workspace.
- Save src/policies/permission.policy.ts.
- Confirm the file ends with the canArchiveProject function.
✔️ Awesome, I've got everything!
Your complete policy file is ready for the focused test.
ⓧ I'd like to double check the full code
export type OwnershipContext = Readonly<{
actorId: string;
ownerId: string;
}>;
export type OrganizationRole = "OWNER" | "ADMIN" | "MEMBER";
export type WorkspaceRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
export type ProjectRole = "ADMIN" | "MEMBER";
type RuntimeRole = string | null | undefined;
const isOwner = ({ actorId, ownerId }: OwnershipContext) =>
actorId === ownerId;
export const canUpdateOrganization = (context: OwnershipContext) =>
isOwner(context);
export const canArchiveOrganization = (context: OwnershipContext) =>
isOwner(context);
export const canManageOrganizationMembers = (role: RuntimeRole) =>
role === "OWNER" || role === "ADMIN";
export const canUpdateWorkspace = (context: OwnershipContext) =>
isOwner(context);
export const canArchiveWorkspace = (context: OwnershipContext) =>
isOwner(context);
export const canManageWorkspaceMembers = (role: RuntimeRole) =>
role === "OWNER" || role === "ADMIN";
export const canUpdateProject = (role: RuntimeRole) =>
role === "ADMIN";
export const canManageProjectMembers = (role: RuntimeRole) =>
role === "ADMIN";
export const canManageBoard = (role: RuntimeRole) =>
role === "ADMIN";
export const canArchiveProject = (context: OwnershipContext) =>
isOwner(context);
Prove the contract is green
The focused test now exercises every exported policy function. Its result shows whether the centralized contract matches the hierarchy you defined.
Before you run the suite, do you expect the earlier module-resolution failure to remain?
- Execute all six permission cases by running this command:
bun test ./src/policies/permission.policy.test.ts
You should see all six permission tests pass with zero failures. That green run means TaskFlow now has an executable permission contract.
Still Seeing a Test Failure?
- Check that every exported function name matches its import in permission.policy.test.ts.
- Confirm both code sections are saved in src/policies/permission.policy.ts.
- Compare each role value with the uppercase strings in the complete policy file.
Help me debug my failing TaskFlow permission policy tests.
Your policy contract is green. Next, you will connect its owner rule to the live workspace update path.
Secure Workspace Updates
Your permission policy now proves that workspace settings belong to the owner. TaskFlow's live update path still accepts workspace admins.
You will first capture that over-privileged behavior in the running application. You will then bring the live mutation in line with the tested contract.
In this step, get ready to:
- Confirm the current workspace admin can save an owner-only setting.
- Route workspace updates through the centralized owner policy.
- Prove each role now receives its intended result.
Reproduce the workspace admin bug
The existing service treats an admin membership as permission to change workspace settings. A baseline request shows the exact behavior your edit must reverse.
Before you submit the update, do you expect the current service to accept an admin's change?
- Switch to an existing TaskFlow session signed in as a workspace admin.
- Select a workspace owned by another user.
- Enter Admin baseline check as the workspace description.
- Submit the workspace settings form.
The change is saved. That successful admin update is the intended shortfall in the current authorization path.
Wire the owner policy into the service
The service currently performs a membership query before allowing an owner or admin to continue. Your centralized authorization policy already contains the stricter ownership rule.
- In Visual Studio Code's file tree, select src/modules/workspaces/workspace.service.ts.
- Find import { createSlug } from "@/utils/slug"; near the top of the file.
- Add the policy import directly below it so the two lines read:
import { createSlug } from "@/utils/slug";
import { canUpdateWorkspace } from "@/policies/permission.policy";
What Does This Import Do?
The import makes the tested canUpdateWorkspace policy available to the workspace service. The service can now reuse the same ownership decision as the regression suite.
- In updateWorkspace, locate the authorization block immediately after the if (!workspace) guard.
- Delete the membership lookup from that authorization block.
- Replace the owner-or-admin condition with this guard:
if (!canUpdateWorkspace({ actorId: userId, ownerId: workspace.ownerId })) {
throw new apiError(403, "You don't have permission to update this workspace.");
}
What Changes in This Path?
- The actorId value identifies the user making the request.
- The ownerId value comes from the workspace record.
- A false result triggers HTTP 403 before the workspace update begins.
- Removing the membership lookup saves one database query on this path.
✔️ Awesome, I've got everything!
Your workspace service now imports the owner policy. Double-check that you have removed the separate membership lookup from updateWorkspace.
ⓧ I'd like to double check the full code
import apiError from "@/utils/apiError";
import {
MembershipStatus,
OrganizationRole,
WorkspaceRole,
} from "@/generated/prisma/enums";
import type {
CreateWorkspaceInput,
UpdateWorkspaceInput,
AddWorkspaceMemberInput,
} from "./workspace.types";
import { createSlug } from "@/utils/slug";
import { canUpdateWorkspace } from "@/policies/permission.policy";
import * as workspaceRepository from "./workspace.repository";
import * as organizationRepository from "@/modules/organization/organization.repository";
import * as authRepository from "@/modules/auth/auth.repository";
import * as notificationService from "@/modules/notifications/notification.service";
import { getWorkspaceFolder, uploadToCloudinary } from "@/utils/cloudinary";
export const createWorkspace = async (organizationSlug: string, ownerId: string, data: CreateWorkspaceInput, file?: Express.Multer.File) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, ownerId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const organizationMember = await organizationRepository.fetchOrganizationMemberByUserId(organization.id, ownerId);
if (!organizationMember || organizationMember.status !== MembershipStatus.ACTIVE) {
throw new apiError(403, "You are not a member of this organization.");
}
if (organizationMember.role !== OrganizationRole.OWNER && organizationMember.role !== OrganizationRole.ADMIN) {
throw new apiError(403, "You don't have permission to create a workspace.");
}
const slug = createSlug(data.name);
let logoUrl: string | undefined;
if (file) {
const uploadResult = await uploadToCloudinary(file.buffer, {
folder: getWorkspaceFolder(slug),
resourceType: "image",
});
logoUrl = uploadResult.url;
}
return workspaceRepository.createWorkspace(
organization.id,
ownerId,
slug,
{
...data,
logoUrl,
},
);
};
export const updateWorkspace = async (organizationSlug: string, workspaceSlug: string, userId: string, data: UpdateWorkspaceInput, file?: Express.Multer.File) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, userId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
if (!canUpdateWorkspace({ actorId: userId, ownerId: workspace.ownerId })) {
throw new apiError(403, "You don't have permission to update this workspace.");
}
let logoUrl: string | undefined;
if (file) {
const uploadResult = await uploadToCloudinary(file.buffer, {
folder: getWorkspaceFolder(workspaceSlug),
resourceType: "image",
});
logoUrl = uploadResult.url;
}
return await workspaceRepository.updateWorkspace(
workspace.id,
{
...data,
...(logoUrl && { logoUrl }),
},
);
};
export const archiveWorkspace = async (organizationSlug: string, workspaceSlug: string, userId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, userId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
const member = await workspaceRepository.fetchWorkspaceMemberByUserId(workspace.id, userId);
if (!member || member.role !== WorkspaceRole.OWNER) {
throw new apiError(403, "Only workspace owner can archive this workspace.");
}
return await workspaceRepository.archiveWorkspace(workspace.id);
};
export const fetchAllWorkspaces = async (organizationSlug: string, userId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const organizationMember = await organizationRepository.fetchOrganizationMemberByUserId(organization.id, userId);
if (!organizationMember || organizationMember.status !== MembershipStatus.ACTIVE) {
throw new apiError(403, "You don't have access to this organization.");
}
return await workspaceRepository.fetchAllWorkspaces(organization.id);
};
export const fetchWorkspace = async (organizationSlug: string, slug: string, userId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, slug, userId);
if (!workspace) {
throw new apiError(404, "Workspace not found or you don't have access to it.");
}
return workspace;
};
export const addWorkspaceMember = async (organizationSlug: string, workspaceSlug: string, requesterId: string, data: AddWorkspaceMemberInput) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, requesterId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const organizationMember = await organizationRepository.fetchOrganizationMemberByUserId(organization.id, requesterId);
if (!organizationMember || organizationMember.status !== MembershipStatus.ACTIVE) {
throw new apiError(403, "You don't have access to this organization.");
}
if (organizationMember.role !== OrganizationRole.OWNER && organizationMember.role !== OrganizationRole.ADMIN) {
throw new apiError(403, "You don't have permission to add workspace members.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, requesterId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
const user = await authRepository.findUserByEmail(data.email);
if (!user) {
throw new apiError(404, "User not found.");
}
const targetOrganizationMember = await organizationRepository.fetchOrganizationMemberByUserId(organization.id, user.id);
if (!targetOrganizationMember || targetOrganizationMember.status !== MembershipStatus.ACTIVE) {
throw new apiError(400, "User is not a member of this organization.");
}
const existingMember = await workspaceRepository.fetchWorkspaceMemberByUserId(workspace.id, user.id);
if (existingMember?.status === MembershipStatus.ACTIVE) {
throw new apiError(409, "User is already a member of this workspace.");
}
const newMember = await workspaceRepository.createWorkspaceMember(
workspace.id,
user.id,
data.role ?? WorkspaceRole.MEMBER,
);
if (user.id !== requesterId) {
await notificationService.createNotification({
userId: user.id,
title: "Added to workspace",
message: `You have been added to workspace "${workspace.name}".`,
});
}
return newMember;
};
export const fetchAllWorkspaceMembers = async (organizationSlug: string, workspaceSlug: string, userId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, userId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
return await workspaceRepository.fetchAllWorkspaceMembers(workspace.id);
};
export const fetchWorkspaceMember = async (organizationSlug: string, workspaceSlug: string, userId: string, memberId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, userId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, userId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
const member = await workspaceRepository.fetchWorkspaceMemberById(workspace.id, memberId);
if (!member) {
throw new apiError(404, "Workspace member not found.");
}
return member;
};
export const updateWorkspaceMemberRole = async (organizationSlug: string, workspaceSlug: string, requesterId: string, memberId: string, role: WorkspaceRole) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, requesterId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, requesterId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
const requester = await workspaceRepository.fetchWorkspaceMemberByUserId(workspace.id, requesterId);
if (!requester) {
throw new apiError(403, "You don't have access to this workspace.");
}
if (requester.role !== WorkspaceRole.OWNER && requester.role !== WorkspaceRole.ADMIN) {
throw new apiError(403, "You don't have permission to update member roles.");
}
const target = await workspaceRepository.fetchWorkspaceMemberById(workspace.id, memberId);
if (!target) {
throw new apiError(404, "Workspace member not found.");
}
if (target.role === WorkspaceRole.OWNER) {
throw new apiError(403, "Workspace owner role cannot be changed.");
}
if (requester.role === WorkspaceRole.ADMIN && target.role === WorkspaceRole.ADMIN) {
throw new apiError(403, "Admin cannot change another admin's role.");
}
if (role === WorkspaceRole.OWNER) {
throw new apiError(403, "Owner role cannot be assigned.");
}
const updatedMember = await workspaceRepository.updateWorkspaceMemberRole(
target.id,
role,
);
if (target.userId !== requesterId) {
await notificationService.createNotification({
userId: target.userId,
title: "Workspace role updated",
message: `Your role in workspace "${workspace.name}" has been changed to ${role}.`,
});
}
return updatedMember;
};
export const removeWorkspaceMember = async (organizationSlug: string, workspaceSlug: string, requesterId: string, memberId: string) => {
const organization = await organizationRepository.findOrganizationBySlug(organizationSlug, requesterId);
if (!organization) {
throw new apiError(404, "Organization not found.");
}
const workspace = await workspaceRepository.findWorkspaceBySlug(organization.id, workspaceSlug, requesterId);
if (!workspace) {
throw new apiError(404, "Workspace not found.");
}
const requester = await workspaceRepository.fetchWorkspaceMemberByUserId(workspace.id, requesterId);
if (!requester) {
throw new apiError(403, "You don't have access to this workspace.");
}
if (requester.role !== WorkspaceRole.OWNER && requester.role !== WorkspaceRole.ADMIN) {
throw new apiError(403, "You don't have permission to remove members.");
}
const target = await workspaceRepository.fetchWorkspaceMemberById(workspace.id, memberId);
if (!target) {
throw new apiError(404, "Workspace member not found.");
}
if (target.role === WorkspaceRole.OWNER) {
throw new apiError(403, "Workspace owner cannot be removed.");
}
if (requester.role === WorkspaceRole.ADMIN && target.role === WorkspaceRole.ADMIN) {
throw new apiError(403, "Admin cannot remove another admin.");
}
const removedMember = await workspaceRepository.removeWorkspaceMember(
target.id,
);
if (target.userId !== requesterId) {
await notificationService.createNotification({
userId: target.userId,
title: "Removed from workspace",
message: `You have been removed from workspace "${workspace.name}".`,
});
}
return removedMember;
};
Verify both role outcomes
The new guard runs before any file upload or database update. Testing both roles proves the denial without breaking legitimate owner access.
- Save src/modules/workspaces/workspace.service.ts.
- Start the updated backend from the backend terminal by running this command:
bun run dev
What Should You See?
Bun starts TaskFlow's watched backend process. Pino confirms readiness with Server is running on http://localhost:8000.
Backend Not Starting?
- Confirm that your terminal prompt points to the backend directory.
- Stop the earlier backend process if it is still using the configured port.
Need help finding the startup problem?
Before the admin submits the same change again, do you expect the saved description to change?
- Return to the TaskFlow session signed in as the same workspace admin.
- Enter Admin should be denied as the workspace description.
- Submit the workspace settings form.
You receive HTTP 403. The workspace description remains unchanged.
Before the owner submits an update, which outcome should confirm that legitimate access still works?
- Switch to the existing TaskFlow session signed in as the workspace owner.
- Enter Owner update confirmed as the workspace description.
- Submit the workspace settings form.
The update succeeds. The saved description now reads Owner update confirmed.
Strong finish. The live workspace mutation now enforces the owner-only contract without blocking its rightful owner.
Workspace settings now follow your tested policy. Next, you will document the permission matrix and measure the policy suite with coverage.
Document and Measure the Contract
Your owner-only workspace update now blocks admins from changing workspace settings. The next goal is to make the complete TaskFlow permission contract easy to review.
A written matrix gives reviewers a shared reference. Repeatable Bun checks give automation evidence that the contract remains enforced.
In this step, get ready to:
- Document the full permission hierarchy in a reviewable matrix.
- Confirm Bun discovers the policy regression suite automatically.
- Generate test coverage evidence for every exported policy function.
Document the permission matrix
A permission matrix turns default-deny authorization into a reference that reviewers can scan before approving future changes.
- In Visual Studio Code's file sidebar, locate backend at the top level of your TaskFlow repository.
- Create docs beside backend if the folder is missing.
- Create Authorization_Policy.md inside docs using the file creation control.
You should now see docs/Authorization_Policy.md in the repository file tree.
- Add the organization rules plus the workspace rules by pasting this first section into docs/Authorization_Policy.md:
# TaskFlow Authorization Policy
TaskFlow uses default-deny authorization. An action is allowed only when the policy explicitly grants it.
## Organization
| Action | Owner | Admin | Member |
| --- | --- | --- | --- |
| Update organization settings | Allow | Deny | Deny |
| Archive organization | Allow | Deny | Deny |
| Manage organization members | Allow | Allow | Deny |
## Workspace
| Action | Owner | Admin | Member | Guest |
| --- | --- | --- | --- | --- |
| Update workspace settings | Allow | Deny | Deny | Deny |
| Archive workspace | Allow | Deny | Deny | Deny |
| Manage workspace members | Allow | Allow | Deny | Deny |
What Does This Matrix Capture?
- The opening statement establishes explicit permission as the condition for access.
- Organization owners control settings plus archival.
- Organization admins receive member-management access.
- Workspace owners control settings plus archival while workspace admins manage members.
- Save docs/Authorization_Policy.md.
- Read the organization rows to confirm each owner-only action shows Allow only in the owner column.
- Read the workspace rows to confirm the admin column shows Allow only for member management.
You should see the organization hierarchy plus the workspace hierarchy expressed as matching rows.
Does the First Half Look Different?
- Check that Authorization_Policy.md is inside the top-level docs folder.
- Check each table row for a leading pipe plus a trailing pipe.
- Check that every separator row uses the same number of columns as its table header.
Ask for help comparing the first two permission tables.
- Place your cursor after the workspace table in docs/Authorization_Policy.md.
- Add the project rules by pasting this final section:
## Project
Projects expose only the `ADMIN` and `MEMBER` roles in the intended product model.
| Action | Project admin | Project member |
| --- | --- | --- |
| Update project settings | Allow | Deny |
| Manage project members | Allow | Deny |
| Manage the board | Allow | Deny |
| Archive the project | Deny | Deny |
Project archival remains reserved for the owner of the parent workspace. Unknown, missing, or obsolete roles receive no project-management permissions.
Why Is the Project Matrix Different?
- The intended project model grants management permissions only to ADMIN.
- The MEMBER role receives no project-management permissions.
- Project archival remains tied to ownership of the parent workspace.
- Unexpected role values receive no privileges under the default-deny rule.
- Save docs/Authorization_Policy.md.
- Read the project table to confirm both columns show Deny for project archival.
- Confirm the final paragraph denies unknown roles plus missing roles.
Your document should now show one default-deny statement followed by organization, workspace, and project matrices.
Is the Project Table Misaligned?
- Check that the project section starts below the final workspace table row.
- Check that the project table has three columns on every row.
- Check that the final policy paragraph sits below the project table.
Ask for help checking the complete authorization document.
✔️ Awesome, I've got everything!
Great. Double-check that you saved docs/Authorization_Policy.md before continuing.
ⓧ I'd like to double check the full code
# TaskFlow Authorization Policy
TaskFlow uses default-deny authorization. An action is allowed only when the policy explicitly grants it.
## Organization
| Action | Owner | Admin | Member |
| --- | --- | --- | --- |
| Update organization settings | Allow | Deny | Deny |
| Archive organization | Allow | Deny | Deny |
| Manage organization members | Allow | Allow | Deny |
## Workspace
| Action | Owner | Admin | Member | Guest |
| --- | --- | --- | --- | --- |
| Update workspace settings | Allow | Deny | Deny | Deny |
| Archive workspace | Allow | Deny | Deny | Deny |
| Manage workspace members | Allow | Allow | Deny | Deny |
## Project
Projects expose only the `ADMIN` and `MEMBER` roles in the intended product model.
| Action | Project admin | Project member |
| --- | --- | --- |
| Update project settings | Allow | Deny |
| Manage project members | Allow | Deny |
| Manage the board | Allow | Deny |
| Archive the project | Deny | Deny |
Project archival remains reserved for the owner of the parent workspace. Unknown, missing, or obsolete roles receive no project-management permissions.
Confirm automatic test discovery
Bun discovers test files through their .test.ts suffix. This check proves the policy suite joins full backend test runs without a separate test script.
Before you run the full suite, do you expect Bun to discover the policy test automatically? Hold that prediction for the next command.
- Switch back to the terminal you used for the TaskFlow backend.
- Confirm the terminal prompt points to the backend directory.
- Run Bun's full backend test discovery with this command:
bun test
How Does Test Discovery Work?
- Bun searches the backend for recognized test file patterns.
- The permission.policy.test.ts suffix makes the policy suite discoverable.
- The full run exercises the same permission contract as the earlier focused run.
You should see Bun identify the permission policy test file. The run should finish with every policy test passing plus zero failures.
Good work. The permission contract now participates in TaskFlow's automatic regression loop.
Policy Test Missing From the Run?
- Confirm your terminal is inside the backend directory.
- Confirm the test file is named permission.policy.test.ts.
- Confirm the test file still imports its functions from ./permission.policy.
Ask for help diagnosing Bun test discovery.
Measure policy coverage
A coverage report records which policy code the tests execute. This baseline confirms every exported permission function participates in the regression suite.
Before you generate the report, do you expect any exported permission function to be missing from coverage? Keep your prediction in mind.
- Generate the backend coverage report by running this command from backend:
bun test --coverage
What Does the Coverage Command Measure?
- The --coverage flag adds a coverage table to the passing test output.
- The row for permission.policy.ts shows how thoroughly the suite exercises the policy module.
- The report creates a repeatable baseline for future permission changes.
You should see the full test suite pass. The coverage table should include permission.policy.ts with every exported policy function exercised.
Coverage Run Not Passing?
- Confirm the standard test run passes before generating coverage.
- Confirm you are running the command from the backend directory.
- Compare permission.policy.test.ts with the saved policy functions if a test fails.
Ask for help interpreting the coverage failure.
- Save the passing coverage output in your pull-request description or internship progress notes.
Strong finish. TaskFlow now has a permission contract reviewers can read plus evidence automation can repeat.
Secret mission
Prove Unknown Roles Are Denied
Prove that obsolete database roles and unexpected input receive no project-management permissions. You will add one table-driven regression test that checks every unsupported role against all three project-management policies.
Clean Up Your Resources
Clean Up Your Resources
Your permission work lives inside the existing TaskFlow repository. This project created no cloud resources.
The local code adds no ongoing cost. Choose whether to keep the changes, pause your work, or remove only the artifacts created in this project.
Resources you used:
- The default-deny authorization policy in src/policies/permission.policy.ts.
- The Bun regression suite in src/policies/permission.policy.test.ts.
- The owner-only workspace update check in src/modules/workspaces/workspace.service.ts.
- The permission matrix in docs/Authorization_Policy.md.
Keep everything running
No action is needed. Choose this option if you want to continue hardening TaskFlow or retain the policy as a regression guard.
- Keep src/policies/permission.policy.ts as the centralized source of permission decisions.
- Keep src/policies/permission.policy.test.ts to catch future permission regressions.
- Keep docs/Authorization_Policy.md as the reviewable role matrix.
- Leave the existing database and third-party integrations unchanged.
The backend stays stopped until you start it again. Your saved policy remains ready for future changes.
Pause - I'll come back to this later
Keep the permission work on disk while leaving TaskFlow stopped. The backend server from the initial verification is already stopped.
- Keep the four authorization artifacts in the repository.
- Leave the backend server stopped.
- Return to the backend directory when you are ready to continue.
The focused permission test from Step 5 gives you a quick restart check. Your existing integrations remain configured for the next session.
Delete - I don't want to use this again
Remove the permission-hardening work while preserving the TaskFlow setup you had before this project. This cleanup leaves your existing database and integrations untouched.
Protect Any Extra Work
The cleanup below targets files and edits from this project. Uncommitted changes inside the same files could be lost.
- Preserve any unrelated changes before continuing.
- Switch back to the backend directory from earlier.
- Remove the three files created in this project by running this command:
rm src/policies/permission.policy.ts src/policies/permission.policy.test.ts docs/Authorization_Policy.md
This removes the centralized policy, its regression suite, and the permission matrix. The rest of the TaskFlow repository remains in place.
- Use Git history to restore src/modules/workspaces/workspace.service.ts from the revision immediately before this project.
- Compare the restored file with that pre-project revision.
The restored workspace service should no longer import or call canUpdateWorkspace.
- Confirm the three deleted files no longer appear in the repository.
- Confirm Git shows no remaining changes for the four paths listed above.
Cleanup complete. You removed the authorization exercise without touching TaskFlow's pre-existing services.
Nice Work!
Nice Work!
You did it! Your TaskFlow backend now enforces a tested permission contract with owner-only workspace updates.
You've learned how to:
- Turn TaskFlow's full role hierarchy into a framework-independent default-deny authorization policy.
- Build a Bun regression suite with automatic test discovery plus a coverage baseline for every exported policy function.
- Harden the live workspace update path with owner-only authorization that returns HTTP 403 for workspace admins. Remove one unnecessary membership query from the same mutation.
- Secret Mission: Add table-driven regression cases that keep obsolete or unexpected project roles denied by default.
Ready to quiz yourself?