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:
554
server/src/routes/access.ts
Normal file
554
server/src/routes/access.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { and, eq, isNull, desc } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import {
|
||||
agentApiKeys,
|
||||
authUsers,
|
||||
invites,
|
||||
joinRequests,
|
||||
} from "@paperclip/db";
|
||||
import {
|
||||
acceptInviteSchema,
|
||||
createCompanyInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
PERMISSION_KEYS,
|
||||
} from "@paperclip/shared";
|
||||
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, agentService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function createInviteToken() {
|
||||
return `pcp_invite_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
function requestIp(req: Request) {
|
||||
const forwarded = req.header("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const first = forwarded.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return req.ip || "unknown";
|
||||
}
|
||||
|
||||
function inviteExpired(invite: typeof invites.$inferSelect) {
|
||||
return invite.expiresAt.getTime() <= Date.now();
|
||||
}
|
||||
|
||||
function isLocalImplicit(req: Request) {
|
||||
return req.actor.type === "board" && req.actor.source === "local_implicit";
|
||||
}
|
||||
|
||||
async function resolveActorEmail(db: Db, req: Request): Promise<string | null> {
|
||||
if (isLocalImplicit(req)) return "local@paperclip.local";
|
||||
const userId = req.actor.userId;
|
||||
if (!userId) return null;
|
||||
const user = await db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return user?.email ?? null;
|
||||
}
|
||||
|
||||
function grantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined,
|
||||
key: "human" | "agent",
|
||||
): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record<string, unknown> | null }> {
|
||||
if (!defaultsPayload || typeof defaultsPayload !== "object") return [];
|
||||
const scoped = defaultsPayload[key];
|
||||
if (!scoped || typeof scoped !== "object") return [];
|
||||
const grants = (scoped as Record<string, unknown>).grants;
|
||||
if (!Array.isArray(grants)) return [];
|
||||
const validPermissionKeys = new Set<string>(PERMISSION_KEYS);
|
||||
const result: Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> = [];
|
||||
for (const item of grants) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.permissionKey !== "string") continue;
|
||||
if (!validPermissionKeys.has(record.permissionKey)) continue;
|
||||
result.push({
|
||||
permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number],
|
||||
scope:
|
||||
record.scope && typeof record.scope === "object" && !Array.isArray(record.scope)
|
||||
? (record.scope as Record<string, unknown>)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function accessRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const access = accessService(db);
|
||||
const agents = agentService(db);
|
||||
|
||||
async function assertInstanceAdmin(req: Request) {
|
||||
if (req.actor.type !== "board") throw unauthorized();
|
||||
if (isLocalImplicit(req)) return;
|
||||
const allowed = await access.isInstanceAdmin(req.actor.userId);
|
||||
if (!allowed) throw forbidden("Instance admin required");
|
||||
}
|
||||
|
||||
async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
if (!req.actor.agentId) throw forbidden();
|
||||
const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey);
|
||||
if (!allowed) throw forbidden("Permission denied");
|
||||
return;
|
||||
}
|
||||
if (req.actor.type !== "board") throw unauthorized();
|
||||
if (isLocalImplicit(req)) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, permissionKey);
|
||||
if (!allowed) throw forbidden("Permission denied");
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/invites",
|
||||
validate(createCompanyInviteSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:invite");
|
||||
|
||||
const token = createInviteToken();
|
||||
const created = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: hashToken(token),
|
||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||
defaultsPayload: req.body.defaultsPayload ?? null,
|
||||
expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
|
||||
invitedByUserId: req.actor.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
|
||||
action: "invite.created",
|
||||
entityType: "invite",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
inviteType: created.inviteType,
|
||||
allowedJoinTypes: created.allowedJoinTypes,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...created,
|
||||
token,
|
||||
inviteUrl: `/invite/${token}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/invites/:token", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: invite.id,
|
||||
companyId: invite.companyId,
|
||||
inviteType: invite.inviteType,
|
||||
allowedJoinTypes: invite.allowedJoinTypes,
|
||||
expiresAt: invite.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(token)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
|
||||
throw notFound("Invite not found");
|
||||
}
|
||||
|
||||
if (invite.inviteType === "bootstrap_ceo") {
|
||||
if (req.body.requestType !== "human") {
|
||||
throw badRequest("Bootstrap invite requires human request type");
|
||||
}
|
||||
if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
|
||||
throw unauthorized("Authenticated user required for bootstrap acceptance");
|
||||
}
|
||||
const userId = req.actor.userId ?? "local-board";
|
||||
const existingAdmin = await access.isInstanceAdmin(userId);
|
||||
if (!existingAdmin) {
|
||||
await access.promoteInstanceAdmin(userId);
|
||||
}
|
||||
const updatedInvite = await db
|
||||
.update(invites)
|
||||
.set({ acceptedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(invites.id, invite.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? invite);
|
||||
res.status(202).json({
|
||||
inviteId: updatedInvite.id,
|
||||
inviteType: updatedInvite.inviteType,
|
||||
bootstrapAccepted: true,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestType = req.body.requestType as "human" | "agent";
|
||||
const companyId = invite.companyId;
|
||||
if (!companyId) throw conflict("Invite is missing company scope");
|
||||
if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) {
|
||||
throw badRequest(`Invite does not allow ${requestType} joins`);
|
||||
}
|
||||
|
||||
if (requestType === "human" && req.actor.type !== "board") {
|
||||
throw unauthorized("Human invite acceptance requires authenticated user");
|
||||
}
|
||||
if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) {
|
||||
throw unauthorized("Authenticated user is required");
|
||||
}
|
||||
if (requestType === "agent" && !req.body.agentName) {
|
||||
throw badRequest("agentName is required for agent join requests");
|
||||
}
|
||||
|
||||
const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null;
|
||||
const created = await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(invites)
|
||||
.set({ acceptedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
|
||||
|
||||
const row = await tx
|
||||
.insert(joinRequests)
|
||||
.values({
|
||||
inviteId: invite.id,
|
||||
companyId,
|
||||
requestType,
|
||||
status: "pending_approval",
|
||||
requestIp: requestIp(req),
|
||||
requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null,
|
||||
requestEmailSnapshot: requestType === "human" ? actorEmail : null,
|
||||
agentName: requestType === "agent" ? req.body.agentName : null,
|
||||
adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
|
||||
capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
|
||||
agentDefaultsPayload: requestType === "agent" ? req.body.agentDefaultsPayload ?? null : null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
return row;
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId:
|
||||
req.actor.type === "agent"
|
||||
? req.actor.agentId ?? "invite-agent"
|
||||
: req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"),
|
||||
action: "join.requested",
|
||||
entityType: "join_request",
|
||||
entityId: created.id,
|
||||
details: { requestType, requestIp: created.requestIp },
|
||||
});
|
||||
|
||||
res.status(202).json(created);
|
||||
});
|
||||
|
||||
router.post("/invites/:inviteId/revoke", async (req, res) => {
|
||||
const id = req.params.inviteId as string;
|
||||
const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null);
|
||||
if (!invite) throw notFound("Invite not found");
|
||||
if (invite.inviteType === "bootstrap_ceo") {
|
||||
await assertInstanceAdmin(req);
|
||||
} else {
|
||||
if (!invite.companyId) throw conflict("Invite is missing company scope");
|
||||
await assertCompanyPermission(req, invite.companyId, "users:invite");
|
||||
}
|
||||
if (invite.acceptedAt) throw conflict("Invite already consumed");
|
||||
if (invite.revokedAt) return res.json(invite);
|
||||
|
||||
const revoked = await db
|
||||
.update(invites)
|
||||
.set({ revokedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(invites.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (invite.companyId) {
|
||||
await logActivity(db, {
|
||||
companyId: invite.companyId,
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
|
||||
action: "invite.revoked",
|
||||
entityType: "invite",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(revoked);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/join-requests", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCompanyPermission(req, companyId, "joins:approve");
|
||||
const query = listJoinRequestsQuerySchema.parse(req.query);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(joinRequests)
|
||||
.where(eq(joinRequests.companyId, companyId))
|
||||
.orderBy(desc(joinRequests.createdAt));
|
||||
const filtered = all.filter((row) => {
|
||||
if (query.status && row.status !== query.status) return false;
|
||||
if (query.requestType && row.requestType !== query.requestType) return false;
|
||||
return true;
|
||||
});
|
||||
res.json(filtered);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const requestId = req.params.requestId as string;
|
||||
await assertCompanyPermission(req, companyId, "joins:approve");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Join request not found");
|
||||
if (existing.status !== "pending_approval") throw conflict("Join request is not pending");
|
||||
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.id, existing.inviteId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite) throw notFound("Invite not found");
|
||||
|
||||
let createdAgentId: string | null = existing.createdAgentId ?? null;
|
||||
if (existing.requestType === "human") {
|
||||
if (!existing.requestingUserId) throw conflict("Join request missing user identity");
|
||||
await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active");
|
||||
const grants = grantsFromDefaults(invite.defaultsPayload as Record<string, unknown> | null, "human");
|
||||
await access.setPrincipalGrants(
|
||||
companyId,
|
||||
"user",
|
||||
existing.requestingUserId,
|
||||
grants,
|
||||
req.actor.userId ?? null,
|
||||
);
|
||||
} else {
|
||||
const created = await agents.create(companyId, {
|
||||
name: existing.agentName ?? "New Agent",
|
||||
role: "general",
|
||||
title: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: existing.capabilities ?? null,
|
||||
adapterType: existing.adapterType ?? "process",
|
||||
adapterConfig:
|
||||
existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object"
|
||||
? (existing.agentDefaultsPayload as Record<string, unknown>)
|
||||
: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
permissions: {},
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
});
|
||||
createdAgentId = created.id;
|
||||
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
||||
const grants = grantsFromDefaults(invite.defaultsPayload as Record<string, unknown> | null, "agent");
|
||||
await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null);
|
||||
}
|
||||
|
||||
const approved = await db
|
||||
.update(joinRequests)
|
||||
.set({
|
||||
status: "approved",
|
||||
approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
||||
approvedAt: new Date(),
|
||||
createdAgentId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(joinRequests.id, requestId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "join.approved",
|
||||
entityType: "join_request",
|
||||
entityId: requestId,
|
||||
details: { requestType: existing.requestType, createdAgentId },
|
||||
});
|
||||
|
||||
res.json(approved);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const requestId = req.params.requestId as string;
|
||||
await assertCompanyPermission(req, companyId, "joins:approve");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Join request not found");
|
||||
if (existing.status !== "pending_approval") throw conflict("Join request is not pending");
|
||||
|
||||
const rejected = await db
|
||||
.update(joinRequests)
|
||||
.set({
|
||||
status: "rejected",
|
||||
rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
||||
rejectedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(joinRequests.id, requestId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "join.rejected",
|
||||
entityType: "join_request",
|
||||
entityId: requestId,
|
||||
details: { requestType: existing.requestType },
|
||||
});
|
||||
|
||||
res.json(rejected);
|
||||
});
|
||||
|
||||
router.post("/join-requests/:requestId/claim-api-key", async (req, res) => {
|
||||
const requestId = req.params.requestId as string;
|
||||
const joinRequest = await db
|
||||
.select()
|
||||
.from(joinRequests)
|
||||
.where(eq(joinRequests.id, requestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!joinRequest) throw notFound("Join request not found");
|
||||
if (joinRequest.requestType !== "agent") throw badRequest("Only agent join requests can claim API keys");
|
||||
if (joinRequest.status !== "approved") throw conflict("Join request must be approved before key claim");
|
||||
if (!joinRequest.createdAgentId) throw conflict("Join request has no created agent");
|
||||
|
||||
const existingKey = await db
|
||||
.select({ id: agentApiKeys.id })
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.agentId, joinRequest.createdAgentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingKey) throw conflict("API key already claimed");
|
||||
|
||||
const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key");
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: joinRequest.companyId,
|
||||
actorType: "system",
|
||||
actorId: "join-claim",
|
||||
action: "agent_api_key.claimed",
|
||||
entityType: "agent_api_key",
|
||||
entityId: created.id,
|
||||
details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId },
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
keyId: created.id,
|
||||
token: created.token,
|
||||
agentId: joinRequest.createdAgentId,
|
||||
createdAt: created.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/members", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const members = await access.listMembers(companyId);
|
||||
res.json(members);
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/members/:memberId/permissions",
|
||||
validate(updateMemberPermissionsSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const memberId = req.params.memberId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:manage_permissions");
|
||||
const updated = await access.setMemberPermissions(
|
||||
companyId,
|
||||
memberId,
|
||||
req.body.grants ?? [],
|
||||
req.actor.userId ?? null,
|
||||
);
|
||||
if (!updated) throw notFound("Member not found");
|
||||
res.json(updated);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
const userId = req.params.userId as string;
|
||||
const result = await access.promoteInstanceAdmin(userId);
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
const userId = req.params.userId as string;
|
||||
const removed = await access.demoteInstanceAdmin(userId);
|
||||
if (!removed) throw notFound("Instance admin role not found");
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
router.get("/admin/users/:userId/company-access", async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
const userId = req.params.userId as string;
|
||||
const memberships = await access.listUserCompanyAccess(userId);
|
||||
res.json(memberships);
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/admin/users/:userId/company-access",
|
||||
validate(updateUserCompanyAccessSchema),
|
||||
async (req, res) => {
|
||||
await assertInstanceAdmin(req);
|
||||
const userId = req.params.userId as string;
|
||||
const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []);
|
||||
res.json(memberships);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
accessService,
|
||||
approvalService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
@@ -26,10 +28,12 @@ import { forbidden } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { runClaudeLogin } from "@paperclip/adapter-claude-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = agentService(db);
|
||||
const access = accessService(db);
|
||||
const approvalsSvc = approvalService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
@@ -43,13 +47,21 @@ export function agentRoutes(db: Db) {
|
||||
|
||||
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") return null;
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: agents:create");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (!canCreateAgents(actorAgent)) {
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
|
||||
if (!allowedByGrant && !canCreateAgents(actorAgent)) {
|
||||
throw forbidden("Missing permission: can create agents");
|
||||
}
|
||||
return actorAgent;
|
||||
@@ -61,11 +73,15 @@ export function agentRoutes(db: Db) {
|
||||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") return true;
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
||||
return access.canUser(companyId, req.actor.userId, "agents:create");
|
||||
}
|
||||
if (!req.actor.agentId) return false;
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
||||
return canCreateAgents(actorAgent);
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
|
||||
return allowedByGrant || canCreateAgents(actorAgent);
|
||||
}
|
||||
|
||||
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
@@ -80,7 +96,13 @@ export function agentRoutes(db: Db) {
|
||||
|
||||
if (actorAgent.id === targetAgent.id) return;
|
||||
if (actorAgent.role === "ceo") return;
|
||||
if (canCreateAgents(actorAgent)) return;
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
targetAgent.companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
if (allowedByGrant || canCreateAgents(actorAgent)) return;
|
||||
throw forbidden("Only CEO or agent creators can modify other agents");
|
||||
}
|
||||
|
||||
@@ -919,6 +941,37 @@ export function agentRoutes(db: Db) {
|
||||
res.status(202).json(run);
|
||||
});
|
||||
|
||||
router.post("/agents/:id/claude-login", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
if (agent.adapterType !== "claude_local") {
|
||||
res.status(400).json({ error: "Login is only supported for claude_local agents" });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = asRecord(agent.adapterConfig) ?? {};
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
|
||||
const result = await runClaudeLogin({
|
||||
runId: `claude-login-${randomUUID()}`,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
},
|
||||
config: runtimeConfig,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request } from "express";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
|
||||
export function assertBoard(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
@@ -8,12 +8,24 @@ export function assertBoard(req: Request) {
|
||||
}
|
||||
|
||||
export function assertCompanyAccess(req: Request, companyId: string) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
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) {
|
||||
const allowedCompanies = req.actor.companyIds ?? [];
|
||||
if (!allowedCompanies.includes(companyId)) {
|
||||
throw forbidden("User does not have access to this company");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getActorInfo(req: Request) {
|
||||
if (req.actor.type === "none") {
|
||||
throw unauthorized();
|
||||
}
|
||||
if (req.actor.type === "agent") {
|
||||
return {
|
||||
actorType: "agent" as const,
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { companyService, logActivity } from "../services/index.js";
|
||||
import { assertBoard } from "./authz.js";
|
||||
import { accessService, companyService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
|
||||
export function companyRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = companyService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
router.get("/", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const result = await svc.list();
|
||||
res.json(result);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(req.actor.companyIds ?? []);
|
||||
res.json(result.filter((company) => allowed.has(company.id)));
|
||||
});
|
||||
|
||||
router.get("/stats", async (_req, res) => {
|
||||
router.get("/stats", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const allowed = req.actor.source === "local_implicit" || req.actor.isInstanceAdmin
|
||||
? null
|
||||
: new Set(req.actor.companyIds ?? []);
|
||||
const stats = await svc.stats();
|
||||
res.json(stats);
|
||||
if (!allowed) {
|
||||
res.json(stats);
|
||||
return;
|
||||
}
|
||||
const filtered = Object.fromEntries(Object.entries(stats).filter(([companyId]) => allowed.has(companyId)));
|
||||
res.json(filtered);
|
||||
});
|
||||
|
||||
router.get("/:companyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await svc.getById(companyId);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -31,7 +50,11 @@ export function companyRoutes(db: Db) {
|
||||
|
||||
router.post("/", validate(createCompanySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
|
||||
throw forbidden("Instance admin required");
|
||||
}
|
||||
const company = await svc.create(req.body);
|
||||
await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active");
|
||||
await logActivity(db, {
|
||||
companyId: company.id,
|
||||
actorType: "user",
|
||||
@@ -47,6 +70,7 @@ export function companyRoutes(db: Db) {
|
||||
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await svc.update(companyId, req.body);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -67,6 +91,7 @@ export function companyRoutes(db: Db) {
|
||||
router.post("/:companyId/archive", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await svc.archive(companyId);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -86,6 +111,7 @@ export function companyRoutes(db: Db) {
|
||||
router.delete("/:companyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await svc.remove(companyId);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { count, sql } from "drizzle-orm";
|
||||
import { instanceUserRoles } from "@paperclip/db";
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared";
|
||||
|
||||
export function healthRoutes() {
|
||||
export function healthRoutes(
|
||||
db?: Db,
|
||||
opts: {
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
authReady: boolean;
|
||||
} = {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
authReady: true,
|
||||
},
|
||||
) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
router.get("/", async (_req, res) => {
|
||||
if (!db) {
|
||||
res.json({ status: "ok" });
|
||||
return;
|
||||
}
|
||||
|
||||
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
|
||||
if (opts.deploymentMode === "authenticated") {
|
||||
const roleCount = await db
|
||||
.select({ count: count() })
|
||||
.from(instanceUserRoles)
|
||||
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: "ok",
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
authReady: opts.authReady,
|
||||
bootstrapStatus,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -11,3 +11,4 @@ export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
@@ -35,6 +37,7 @@ const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
@@ -78,6 +81,31 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
|
||||
if (agent.role === "ceo") return true;
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
async function assertCanAssignTasks(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
|
||||
if (!allowed) throw forbidden("Missing permission: tasks:assign");
|
||||
return;
|
||||
}
|
||||
if (req.actor.type === "agent") {
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
|
||||
if (allowedByGrant) return;
|
||||
const actorAgent = await agentsSvc.getById(req.actor.agentId);
|
||||
if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
|
||||
throw forbidden("Missing permission: tasks:assign");
|
||||
}
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
function requireAgentRunId(req: Request, res: Response) {
|
||||
if (req.actor.type !== "agent") return null;
|
||||
const runId = req.actor.runId?.trim();
|
||||
@@ -124,15 +152,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await svc.getByIdentifier(rawId);
|
||||
if (issue) {
|
||||
return issue.id;
|
||||
}
|
||||
}
|
||||
return rawId;
|
||||
}
|
||||
|
||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
||||
router.param("id", async (req, res, next, rawId) => {
|
||||
try {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await svc.getByIdentifier(rawId);
|
||||
if (issue) {
|
||||
req.params.id = issue.id;
|
||||
}
|
||||
}
|
||||
req.params.id = await normalizeIssueIdentifier(rawId);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
|
||||
router.param("issueId", async (req, res, next, rawId) => {
|
||||
try {
|
||||
req.params.issueId = await normalizeIssueIdentifier(rawId);
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -240,6 +283,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, companyId);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const issue = await svc.create(companyId, {
|
||||
@@ -285,6 +331,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
const assigneeWillChange =
|
||||
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
|
||||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
|
||||
if (assigneeWillChange) {
|
||||
await assertCanAssignTasks(req, existing.companyId);
|
||||
}
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
|
||||
@@ -344,8 +396,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
|
||||
}
|
||||
|
||||
const assigneeChanged =
|
||||
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
|
||||
const assigneeChanged = assigneeWillChange;
|
||||
|
||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||
void (async () => {
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclip/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const access = accessService(db);
|
||||
|
||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const badges = await svc.get(companyId);
|
||||
let canApproveJoins = false;
|
||||
if (req.actor.type === "board") {
|
||||
canApproveJoins =
|
||||
req.actor.source === "local_implicit" ||
|
||||
Boolean(req.actor.isInstanceAdmin) ||
|
||||
(await access.canUser(companyId, req.actor.userId, "joins:approve"));
|
||||
} else if (req.actor.type === "agent" && req.actor.agentId) {
|
||||
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
||||
}
|
||||
|
||||
const joinRequestCount = canApproveJoins
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(joinRequests)
|
||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, { joinRequests: joinRequestCount });
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user