feat(server): integrate Better Auth, access control, and deployment mode startup
Wire up Better Auth for session-based authentication. Add actor middleware that resolves local_trusted mode to an implicit board actor and authenticated mode to Better Auth sessions. Add access service with membership, permission, invite, and join-request management. Register access routes for member/invite/ join-request CRUD. Update health endpoint to report deployment mode and bootstrap status. Enforce tasks:assign and agents:create permissions in issue and agent routes. Add deployment mode validation at startup with guardrails (loopback-only for local_trusted, auth config required for authenticated). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agentApiKeys, agents } from "@paperclip/db";
|
||||
import { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclip/db";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import type { DeploymentMode } from "@paperclip/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function actorMiddleware(db: Db): RequestHandler {
|
||||
interface ActorMiddlewareOptions {
|
||||
deploymentMode: DeploymentMode;
|
||||
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
|
||||
}
|
||||
|
||||
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "board" };
|
||||
req.actor =
|
||||
opts.deploymentMode === "local_trusted"
|
||||
? { type: "board", userId: "local-board", isInstanceAdmin: true, source: "local_implicit" }
|
||||
: { type: "none", source: "none" };
|
||||
|
||||
const runIdHeader = req.header("x-paperclip-run-id");
|
||||
|
||||
const authHeader = req.header("authorization");
|
||||
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
||||
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
|
||||
const session = await opts.resolveSession(req);
|
||||
if (session?.user?.id) {
|
||||
const userId = session.user.id;
|
||||
const [roleRow, memberships] = await Promise.all([
|
||||
db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId,
|
||||
companyIds: memberships.map((row) => row.companyId),
|
||||
isInstanceAdmin: Boolean(roleRow),
|
||||
runId: runIdHeader ?? undefined,
|
||||
source: "session",
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (runIdHeader) req.actor.runId = runIdHeader;
|
||||
next();
|
||||
return;
|
||||
@@ -64,6 +107,7 @@ export function actorMiddleware(db: Db): RequestHandler {
|
||||
companyId: claims.company_id,
|
||||
keyId: undefined,
|
||||
runId: runIdHeader || claims.run_id || undefined,
|
||||
source: "agent_jwt",
|
||||
};
|
||||
next();
|
||||
return;
|
||||
@@ -91,6 +135,7 @@ export function actorMiddleware(db: Db): RequestHandler {
|
||||
companyId: key.companyId,
|
||||
keyId: key.id,
|
||||
runId: runIdHeader || undefined,
|
||||
source: "agent_key",
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
@@ -51,6 +51,14 @@ export function boardMutationGuard(): RequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local-trusted mode uses an implicit board actor for localhost-only development.
|
||||
// In this mode, origin/referer headers can be omitted by some clients for multipart
|
||||
// uploads; do not block those mutations.
|
||||
if (req.actor.source === "local_implicit") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTrustedBoardMutationRequest(req)) {
|
||||
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user