Tighten authenticated company access

Restrict normal board company access to explicit memberships, expose authenticated user identity in the session UI, and cover the changes with server and authenticated E2E tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dottaandPaperclip committed 2026-04-01 08:36:25 -05:00
1 parent 6d05221099
commit 192674a1db
10 files changed
+268 -26

No files matched your search

@@ -0,0 +1,61 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { actorMiddleware } from "../middleware/auth.js";
function createSelectChain(rows: unknown[]) {
return {
from() {
return {
where() {
return Promise.resolve(rows);
},
};
},
};
}
function createDb() {
return {
select: vi
.fn()
.mockImplementationOnce(() => createSelectChain([]))
.mockImplementationOnce(() => createSelectChain([])),
} as any;
}
describe("actorMiddleware authenticated session profile", () => {
it("preserves the signed-in user name and email on the board actor", async () => {
const app = express();
app.use(
actorMiddleware(createDb(), {
deploymentMode: "authenticated",
resolveSession: async () => ({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "User One",
email: "user@example.com",
},
}),
}),
);
app.get("/actor", (req, res) => {
res.json(req.actor);
});
const res = await request(app).get("/actor");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
type: "board",
userId: "user-1",
userName: "User One",
userEmail: "user@example.com",
source: "session",
companyIds: [],
memberships: [],
isInstanceAdmin: false,
});
});
});
@@ -45,4 +45,34 @@ describe("assertCompanyAccess", () => {
expect(() => assertCompanyAccess(req, "company-1")).toThrow("Viewer access is read-only");
});
it("rejects signed-in instance admins without explicit company access", () => {
const req = makeReq({
method: "GET",
actor: {
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: [],
memberships: [],
},
});
expect(() => assertCompanyAccess(req, "company-1")).toThrow("User does not have access to this company");
});
it("allows local trusted board access without explicit membership", () => {
const req = makeReq({
method: "GET",
actor: {
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
},
});
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
});
});
@@ -194,3 +194,52 @@ describe("PATCH /api/companies/:companyId/branding", () => {
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});
describe("GET /api/companies", () => {
beforeEach(() => {
mockCompanyService.list.mockReset();
mockCompanyService.stats.mockReset();
});
it("filters list results to explicit memberships for signed-in instance admins", async () => {
mockCompanyService.list.mockResolvedValue([
{ ...createCompany(), id: "company-1", issuePrefix: "PAP" },
{ ...createCompany(), id: "company-2", issuePrefix: "OPS" },
]);
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
});
const res = await request(app).get("/api/companies");
expect(res.status).toBe(200);
expect(res.body.map((company: { id: string }) => company.id)).toEqual(["company-1"]);
});
it("filters stats results to explicit memberships for signed-in instance admins", async () => {
mockCompanyService.stats.mockResolvedValue({
"company-1": { activeAgents: 2 },
"company-2": { activeAgents: 1 },
});
const app = createApp({
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
});
const res = await request(app).get("/api/companies/stats");
expect(res.status).toBe(200);
expect(res.body).toEqual({
"company-1": { activeAgents: 2 },
});
});
});
+2 -2
View File
@@ -117,8 +117,8 @@ export async function createApp(
},
user: {
id: req.actor.userId,
email: null,
name: req.actor.source === "local_implicit" ? "Local Board" : null,
email: req.actor.userEmail ?? null,
name: req.actor.userName ?? null,
},
});
});
+12 -1
View File
@@ -23,7 +23,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return async (req, _res, next) => {
req.actor =
opts.deploymentMode === "local_trusted"
? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" }
? {
type: "board",
userId: "local-board",
userName: "Local Board",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
}
: { type: "none", source: "none" };
const runIdHeader = req.header("x-paperclip-run-id");
@@ -66,6 +73,8 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
req.actor = {
type: "board",
userId,
userName: session.user.name ?? null,
userEmail: session.user.email ?? null,
companyIds: memberships.map((row) => row.companyId),
memberships,
isInstanceAdmin: Boolean(roleRow),
@@ -95,6 +104,8 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
+1 -1
View File
@@ -22,7 +22,7 @@ export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (req.actor.type === "board" && req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
const allowedCompanies = req.actor.companyIds ?? [];
if (!allowedCompanies.includes(companyId)) {
throw forbidden("User does not have access to this company");
+2 -2
View File
@@ -60,7 +60,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
router.get("/", async (req, res) => {
assertBoard(req);
const result = await svc.list();
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
if (req.actor.source === "local_implicit") {
res.json(result);
return;
}
@@ -70,7 +70,7 @@ export function companyRoutes(db: Db, storage?: StorageService) {
router.get("/stats", async (req, res) => {
assertBoard(req);
const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin
const allowed = req.actor.source === "local_implicit"
? null
: new Set(req.actor.companyIds ?? []);
const stats = await svc.stats();
+2
View File
@@ -6,6 +6,8 @@ declare global {
actor: {
type: "board" | "agent" | "none";
userId?: string;
userName?: string | null;
userEmail?: string | null;
agentId?: string;
companyId?: string;
companyIds?: string[];
@@ -278,6 +278,10 @@ test.describe("Multi-user: authenticated mode", () => {
const company = await createCompanyForSession(page, companyName);
const companyPrefix = company.issuePrefix ?? company.id;
await page.goto(`${BASE}/${companyPrefix}/dashboard`);
await expect(page.getByTestId("layout-account-menu-trigger")).toContainText(ownerUser.name);
await page.getByTestId("layout-account-menu-trigger").click();
await expect(page.getByText(ownerUser.email)).toBeVisible();
const inviteUrl = await createAuthenticatedInvite(page, companyPrefix);
const invited = await newPage(browser);
+105 -20
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpen, LogOut, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
@@ -23,6 +23,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { authApi, type AuthSession } from "../api/auth";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
@@ -33,7 +34,16 @@ import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Identity } from "./Identity";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
@@ -46,7 +56,78 @@ function readRememberedInstanceSettingsPath(): string {
}
}
function getSessionDisplayName(session: AuthSession) {
return session.user.name?.trim() || session.user.email?.trim() || session.user.id;
}
function FooterPrimaryAction({
session,
onSignOut,
signingOut,
}: {
session: AuthSession | null;
onSignOut: () => Promise<void>;
signingOut: boolean;
}) {
if (!session) {
return (
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
);
}
const displayName = getSessionDisplayName(session);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="layout-account-menu-trigger"
className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent/50"
aria-label="Open account menu"
>
<Identity name={displayName} size="sm" className="min-w-0 flex-1" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="space-y-0.5">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="truncate text-xs font-normal text-muted-foreground">
{session.user.email?.trim() || session.user.id}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="https://docs.paperclip.ing/" target="_blank" rel="noopener noreferrer">
<BookOpen className="h-4 w-4" />
Documentation
</a>
</DropdownMenuItem>
<DropdownMenuItem
data-testid="layout-account-menu-sign-out"
disabled={signingOut}
onClick={() => {
void onSignOut();
}}
>
<LogOut className="h-4 w-4" />
{signingOut ? "Signing out..." : "Sign out"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function Layout() {
const queryClient = useQueryClient();
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { togglePanelVisible } = usePanel();
@@ -67,6 +148,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [isSigningOut, setIsSigningOut] = useState(false);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
@@ -85,6 +167,25 @@ export function Layout() {
},
refetchIntervalInBackground: true,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: health?.deploymentMode === "authenticated",
retry: false,
});
const handleSignOut = useCallback(async () => {
if (isSigningOut) return;
setIsSigningOut(true);
try {
await authApi.signOut();
queryClient.setQueryData(queryKeys.auth.session, null);
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
navigate("/auth", { replace: true });
} finally {
setIsSigningOut(false);
}
}, [isSigningOut, navigate, queryClient]);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
@@ -296,15 +397,7 @@ export function Layout() {
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
<FooterPrimaryAction session={session ?? null} onSignOut={handleSignOut} signingOut={isSigningOut} />
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
@@ -354,15 +447,7 @@ export function Layout() {
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
<FooterPrimaryAction session={session ?? null} onSignOut={handleSignOut} signingOut={isSigningOut} />
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>