Files
PaperClipAI/server/src/middleware/auth.ts
T

1062 lines
38 KiB
TypeScript

import { createHash, timingSafeEqual } from "node:crypto";
import type { Request, RequestHandler } from "express";
import { and, eq, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentApiKeys,
agents,
authUsers,
companies,
companyMemberships,
heartbeatRuns,
instanceUserRoles,
} from "@paperclipai/db";
import {
MAX_ISSUE_PREFIX_ATTEMPTS,
deriveIssuePrefixBase,
isIssuePrefixConflict,
issuePrefixSuffixForAttempt,
pickAvailableIssuePrefix,
rekeyCompanyIssueIdentifiers,
} from "../services/issue-prefix.js";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import { agentRunWritesRevoked } from "../agent-run-cancellation.js";
import { loadAgentRunAuthentication } from "./agent-run-authentication.js";
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { captureRunIdentity } from "../services/run-identity.js";
import { boardAuthService } from "../services/board-auth.js";
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { beginIdleTrackedWork } from "../services/task-admission.js";
import { notifyChatRunMilestones } from "../services/chat-run-milestone-notifications.js";
export {
isTransientDbConnectionError,
retryIdempotentDatabaseOperation as retryOnTransientDbConnectionError,
} from "../database-retry.js";
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
const CLOUD_TENANT_WRITE_DEBOUNCE_MAX = 1_000;
const cloudTenantWriteDebounces = new WeakMap<Db, Map<string, { fingerprint: string; syncedAt: number }>>();
function cloudTenantWriteDebounceFor(db: Db) {
let debounce = cloudTenantWriteDebounces.get(db);
if (!debounce) {
debounce = new Map();
cloudTenantWriteDebounces.set(db, debounce);
}
return debounce;
}
function pruneCloudTenantWriteDebounce(
debounce: Map<string, { fingerprint: string; syncedAt: number }>,
nowMs: number,
) {
for (const [subject, entry] of debounce) {
if (entry.syncedAt <= nowMs - CLOUD_TENANT_WRITE_DEBOUNCE_MS) debounce.delete(subject);
}
while (debounce.size > CLOUD_TENANT_WRITE_DEBOUNCE_MAX) {
const oldestSubject = debounce.keys().next().value;
if (!oldestSubject) break;
debounce.delete(oldestSubject);
}
}
import { resolveOwnerInstanceAdmin } from "../services/cloud-tenant-authority.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import { forbidden, unauthorized, unprocessable } from "../errors.js";
export { isCloudManagedInstance } from "../services/cloud-instance.js";
import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function normalizeOptionalString(value: string | null | undefined) {
return value?.trim() || null;
}
function invalidAgentTokenMessage(token: string) {
try {
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")) as {
exp?: unknown;
};
if (typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000)) {
return "Expired agent token; obtain fresh credentials and retry";
}
} catch {
// Malformed and incorrectly signed tokens share the generic failure below.
}
return "Agent token did not verify; obtain fresh credentials and retry";
}
async function resolveLegacyRunResponsibleUserId(
db: Db,
input: { companyId: string; agentId: string; runId: string },
) {
if (!isUuidLike(input.runId)) return null;
const run = await db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId),
),
)
.then((rows) => rows[0] ?? null);
return normalizeOptionalString(run?.responsibleUserId);
}
async function loadResponsibleUserMemberships(
db: Db,
input: { companyId: string; userId: string | null },
) {
if (!input.userId) return [];
const [user, memberships] = await Promise.all([
db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.id, input.userId))
.then((rows) => rows[0] ?? null),
db
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, input.userId),
eq(companyMemberships.status, "active"),
),
),
]);
return user ? memberships : [];
}
/**
* The user's own active company memberships — the exact company scope a
* locally authenticated session actor carries. Shared by the session path
* and the Cloud trusted-header path so both resolve the same access set.
*/
async function loadActiveUserCompanyMemberships(db: Db, userId: string) {
return db
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
);
}
async function auditAgentJwtRunHeaderMismatch(
db: Db,
input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string },
) {
try {
// The claimed run id is untrusted input from a rejected token. activity_log
// .run_id is a real FK to heartbeat_runs, so writing it unconditionally
// makes the insert fail — and the catch swallows it — exactly when the
// claim is bogus. Keep the claim in entityId/details and populate the FK
// column only when the row actually exists.
const claimedRunExists = isUuidLike(input.claimRunId)
? await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.claimRunId))
.then((rows) => rows.length > 0)
: false;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
action: "auth.agent_jwt_run_header_mismatch",
entityType: "heartbeat_run",
entityId: input.claimRunId,
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
...(claimedRunExists ? { runId: input.claimRunId } : {}),
details: {
claimRunId: input.claimRunId,
headerRunId: input.headerRunId,
method: input.method,
url: input.url,
},
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId, agentId: input.agentId, claimRunId: input.claimRunId },
"Failed to audit rejected agent JWT run header mismatch",
);
}
}
async function auditAgentKeyMissingResponsibleUser(
db: Db,
input: { companyId: string; agentId: string; keyId: string; method: string; url: string },
) {
try {
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
action: "auth.agent_key_missing_responsible_user",
entityType: "agent_api_key",
entityId: input.keyId,
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
details: {
method: input.method,
url: input.url,
},
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId, agentId: input.agentId, keyId: input.keyId },
"Failed to audit rejected agent key without responsible user binding",
);
}
}
interface ActorMiddlewareOptions {
deploymentMode: DeploymentMode;
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
}
const publicRoutineWebhookPath = /^\/api\/routine-triggers\/public\/[a-f0-9]{24}\/fire\/?$/i;
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
const boardAuth = boardAuthService(db);
const authenticate: RequestHandler = async (req, _res, next) => {
req.actor =
opts.deploymentMode === "local_trusted"
? {
type: "board",
userId: "local-board",
userName: "Local Board",
userEmail: null,
isInstanceAdmin: true,
source: "local_implicit",
}
: { type: "none", source: "none" };
// Routine ingress authenticates its own bearer/signature. Never interpret
// webhook credentials as agent keys or attach an ambient browser session.
if (req.method === "POST" && publicRoutineWebhookPath.test(req.path)) {
req.actor = { type: "none", source: "none" };
next();
return;
}
const runIdHeader = req.header("x-paperclip-run-id");
const authHeader = req.header("authorization");
const hasBearerCredentials = /^bearer(?:\s|$)/i.test(authHeader ?? "");
// Public MCP gateway protocol requests carry a pcgw_* bearer that is
// validated by the gateway service itself. Do not interpret that bearer as
// a board key or agent JWT here: doing so rejects the MCP handshake before
// the protocol route can verify its run-scoped credential. Keep this bypass
// restricted to the unguessable public gateway path; all /api routes retain
// the normal actor authentication path below.
if (hasBearerCredentials && publicMcpGatewayProtocolPath.test(req.path)) {
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
if (!hasBearerCredentials) {
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
const cloudTenantActor = await resolveCloudTenantActor(db, req);
if (cloudTenantActor) {
req.actor = {
...cloudTenantActor,
runId: runIdHeader ?? undefined,
};
next();
return;
}
let session: BetterAuthSessionResult | null = null;
try {
session = await opts.resolveSession(req);
} catch (err) {
logger.warn(
{ err, method: req.method, url: req.originalUrl },
"Failed to resolve auth session from request headers",
);
}
if (session?.user?.id && session.session?.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),
loadActiveUserCompanyMemberships(db, userId),
]);
req.actor = {
type: "board",
userId,
sessionId: session.session.id,
userName: session.user.name ?? null,
userEmail: session.user.email ?? null,
companyIds: memberships.map((row) => row.companyId),
memberships,
isInstanceAdmin: Boolean(roleRow),
runId: runIdHeader ?? undefined,
source: "session",
};
next();
return;
}
}
if (runIdHeader) req.actor.runId = runIdHeader;
next();
return;
}
const token = authHeader!.slice("bearer".length).trim();
if (!token) {
next(unauthorized("Empty bearer token; provide valid agent credentials and retry"));
return;
}
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
if (boardKey) {
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
if (access.user) {
await boardAuth.touchBoardApiKey(boardKey.id);
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,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};
next();
return;
}
}
const tokenHash = hashToken(token);
const key = await db
.select()
.from(agentApiKeys)
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
.then((rows) => rows[0] ?? null);
if (!key) {
const claims = verifyLocalAgentJwt(token);
if (!claims) {
next(unauthorized(invalidAgentTokenMessage(token)));
return;
}
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, claims.sub))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
next(unauthorized("Agent record is missing or belongs to another company; obtain fresh credentials and retry"));
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
}
if (agentRecord.status === "pending_approval") {
next(unauthorized("Agent is pending approval and cannot authenticate"));
return;
}
const normalizedRunIdHeader = normalizeOptionalString(runIdHeader);
if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) {
await auditAgentJwtRunHeaderMismatch(db, {
companyId: claims.company_id,
agentId: claims.sub,
claimRunId: claims.run_id,
headerRunId: normalizedRunIdHeader,
method: req.method,
url: req.originalUrl,
});
next(
unprocessable("X-Paperclip-Run-Id does not match signed agent JWT run_id", {
code: "agent_jwt_run_id_mismatch",
claimRunId: claims.run_id,
headerRunId: normalizedRunIdHeader,
}),
);
return;
}
const [identityRun] = await loadAgentRunAuthentication(db, {
runId: claims.run_id,
companyId: claims.company_id,
agentId: claims.sub,
});
if (agentRunWritesRevoked(identityRun)
&& !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
const conversation = identityRun?.contextSnapshot?.conversationMode === true;
_res.status(403).json({ error: conversation ? "This conversation turn was cancelled" : "This run was cancelled",
code: conversation ? "conversation_turn_cancelled" : "agent_run_cancelled" });
return;
}
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
if (claims.authorizing_user_id) {
// This signed projection exists only for internal native tools. Recheck
// the admitted run and current immutable binding on every API call.
try {
const { parseNativeExecutionInput } = await import("../vendor/paperclip-runner/index.js");
const { museRunnerBroker } = await import("../services/muse-runner-broker.js");
const [nativeRun] = await db.select({ status: heartbeatRuns.status, runtimeMode: heartbeatRuns.runtimeMode,
nativeExecutionInput: sql<unknown>`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'`,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
const execution = parseNativeExecutionInput(nativeRun?.nativeExecutionInput);
if (nativeRun?.status !== "running" || nativeRun.runtimeMode !== "native" || execution.schema !== "paperclip.native-execution-input.v8"
|| execution.binding.runId !== claims.run_id || execution.binding.agentId !== claims.sub || execution.binding.companyId !== claims.company_id
|| await museRunnerBroker(db).authorizingUserIdForBinding(execution.provider.binding) !== claims.authorizing_user_id) {
throw new Error("Muse internal tool authority mismatch");
}
await museRunnerBroker(db).assertRunAuthority(execution);
} catch {
_res.status(403).json({ error: "Muse native tool authority is unavailable" });
return;
}
}
const onBehalfOfUserId = identityRun?.activeIdentityContextId
? identityRun.responsibleUserId
: claims.responsible_user_id !== undefined
? normalizeOptionalString(claims.responsible_user_id)
: await resolveLegacyRunResponsibleUserId(db, {
companyId: claims.company_id,
agentId: claims.sub,
runId: claims.run_id,
});
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
companyId: claims.company_id,
userId: onBehalfOfUserId,
});
req.actor = {
type: "agent",
agentId: claims.sub,
companyId: claims.company_id,
keyId: undefined,
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
runId: claims.run_id,
onBehalfOfUserId,
...(claims.authorizing_user_id ? { authorizingUserId: claims.authorizing_user_id } : {}),
identityContextId: identityRun?.activeIdentityContextId ?? null,
onBehalfOfMemberships,
source: "agent_jwt",
};
next();
return;
}
await db
.update(agentApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(agentApiKeys.id, key.id));
const agentRecord = await db
.select()
.from(agents)
.where(eq(agents.id, key.agentId))
.then((rows) => rows[0] ?? null);
if (!agentRecord || agentRecord.companyId !== key.companyId) {
next(unauthorized("Agent record is missing or belongs to another company; obtain fresh credentials and retry"));
return;
}
if (agentRecord.lifecycleHolds?.includes("setup")) {
next(unauthorized("Agent setup is incomplete and cannot authenticate"));
return;
}
if (agentRecord.status === "terminated") {
next(unauthorized("Agent is terminated and cannot authenticate"));
return;
}
if (agentRecord.status === "pending_approval") {
next(unauthorized("Agent is pending approval and cannot authenticate"));
return;
}
const responsibleUserId = normalizeOptionalString(key.responsibleUserId);
if (!responsibleUserId) {
await auditAgentKeyMissingResponsibleUser(db, {
companyId: key.companyId,
agentId: key.agentId,
keyId: key.id,
method: req.method,
url: req.originalUrl,
});
next(forbidden("Responsible user is unavailable for this agent key", {
code: "RESPONSIBLE_USER_UNAVAILABLE",
}));
return;
}
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId,
keyId: key.id,
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
onBehalfOfUserId: responsibleUserId,
onBehalfOfMemberships: await loadResponsibleUserMemberships(db, {
companyId: key.companyId,
userId: responsibleUserId,
}),
runId: runIdHeader || undefined,
source: "agent_key",
};
next();
};
return async (req, res, next) => {
// Health and task-drain requests bypass tenant admission, but resolving
// their actor can still write users, companies, memberships and key usage.
// Finish all authentication before entering the next handler: the report
// must count concurrent authentication, without counting its own auth.
const finish = beginIdleTrackedWork();
let continueRequest: (() => void) | undefined;
try {
await authenticate(req, res, (error?: unknown) => {
continueRequest = () => next(error);
});
} finally {
finish();
}
continueRequest?.();
};
}
/**
* Minimal header accessor `resolveCloudTenantActor` needs. Express `Request`
* satisfies it directly; websocket upgrade paths adapt a raw
* `IncomingMessage` with {@link cloudActorHeaderSourceFromHeaders} since
* trusted-header authentication must work identically for upgrades — a
* cloud-proxied browser has no local Better Auth session to fall back on.
*/
export interface CloudActorHeaderSource {
header(name: string): string | undefined;
}
/** Adapts a raw header map (e.g. `IncomingMessage.headers`) to {@link CloudActorHeaderSource}. */
export function cloudActorHeaderSourceFromHeaders(
headers: Record<string, string | string[] | undefined>,
): CloudActorHeaderSource {
return {
header(name: string) {
const value = headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
},
};
}
/**
* Trusted-header actor resolution with bounded transient-connection retries.
* The tenant sync inside is idempotent end to end — every write is an
* upsert/on-conflict/delete and the write debounce records only after the
* whole sync succeeds — so replaying it after a dropped connection is safe,
* and turns a golden-path authentication 500 into a served request.
*/
export async function resolveCloudTenantActor(
db: Db,
req: CloudActorHeaderSource,
): Promise<Express.Request["actor"] | null> {
return retryIdempotentDatabaseOperation(() => resolveCloudTenantActorOnce(db, req));
}
async function resolveCloudTenantActorOnce(
db: Db,
req: CloudActorHeaderSource,
): Promise<Express.Request["actor"] | null> {
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!expectedToken) return null;
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
const paperclipCompanyName = req
.header("x-paperclip-cloud-paperclip-company-name")
?.trim();
const companyId = cloudTenantCompanyId(stackId);
const companyName = paperclipCompanyName || humanizeCloudStackSlug(stackId);
const now = new Date();
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
const syncFingerprint = [userEmail, userName, stackId, stackRole, paperclipCompanyId ?? ""].join(":");
const cloudTenantWriteDebounce = cloudTenantWriteDebounceFor(db);
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, now.getTime());
const previousSync = cloudTenantWriteDebounce.get(userId);
const shouldSync = previousSync?.fingerprint !== syncFingerprint
|| previousSync.syncedAt <= now.getTime() - CLOUD_TENANT_WRITE_DEBOUNCE_MS;
let effectiveMembership: { companyId: string; membershipRole: string | null; status: string } = {
companyId,
membershipRole,
status: "active",
};
if (shouldSync) await db
.insert(authUsers)
.values({
id: userId,
name: userName,
email: userEmail,
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authUsers.id,
set: {
name: userName,
email: userEmail,
emailVerified: true,
updatedAt: now,
},
});
// Earlier cloud_tenant builds granted every tenant user `instance_admin`.
// Stale rows from those deployments would still elevate this user through
// the BetterAuth session path, board API keys, and the authorization
// service's own instanceUserRoles lookup — so actively purge them on every
// trusted-header authentication instead of merely no longer inserting them.
await db
.delete(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")));
if (shouldSync) await insertCloudTenantCompany(db, { companyId, companyName, now });
if (shouldSync && paperclipCompanyName) {
await repairCloudTenantCompanyName(db, {
companyId,
paperclipCompanyId,
paperclipCompanyName,
now,
});
}
// Runs after the name repair so the prefix derives from the repaired name.
// The helper self-gates on the legacy markers, so it is a no-op once the
// company has been repaired or was claimed by a current build.
if (shouldSync) {
await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now });
}
if (shouldSync) {
effectiveMembership = await db.transaction(async tx => {
const [existing] = await tx.select({
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
}).from(companyMemberships).where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
)).for("update");
// A trusted-header sync can restore publication authority. Serialize the
// comparison with the upsert so unchanged profile syncs remain quiet.
if (!existing || existing.status !== "active" || existing.membershipRole === "viewer") {
await notifyChatRunMilestones(tx, { companyId });
}
return tx
.insert(companyMemberships)
.values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
companyMemberships.companyId,
companyMemberships.principalType,
companyMemberships.principalId,
],
set: {
status: "active",
membershipRole,
updatedAt: now,
},
})
.returning()
.then((rows) => rows[0] ?? {
companyId,
membershipRole,
status: "active",
});
});
}
// Without instance-admin elevation, cloud tenant users are authorized purely
// through company-scoped permission grants — seed the same role defaults the
// regular membership flows create.
if (shouldSync) await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: userId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
grantedByUserId: null,
});
if (shouldSync) {
cloudTenantWriteDebounce.delete(userId);
cloudTenantWriteDebounce.set(userId, { fingerprint: syncFingerprint, syncedAt: Date.now() });
pruneCloudTenantWriteDebounce(cloudTenantWriteDebounce, Date.now());
}
// The stack's seeded company is only where Cloud provisioned this user.
// Companies created afterwards on the instance (imports, in-app company
// creation) attach real membership rows for the user, so union those with
// the pinned primary — the same active-membership scope a locally
// authenticated session actor carries. Strictly this user's own rows; the
// membership-creating flows seed their own permission grants, so nothing
// needs seeding per request here. A read failure degrades to the pinned
// primary instead of blocking authentication, mirroring the fail-closed
// owner-elevation resolution below.
let additionalMemberships: { companyId: string; membershipRole: string | null; status: string }[] =
[];
try {
additionalMemberships = (await loadActiveUserCompanyMemberships(db, userId)).filter(
(row) => row.companyId !== companyId,
);
} catch (err) {
logger.warn(
{ err, userId, stackId },
"Failed to load cloud tenant user's company memberships; scoping actor to the stack's primary company",
);
}
return {
type: "board",
userId,
userName,
userEmail,
companyIds: [companyId, ...additionalMemberships.map((row) => row.companyId)],
memberships: [
{
companyId,
membershipRole: effectiveMembership.membershipRole ?? membershipRole,
status: effectiveMembership.status,
},
...additionalMemberships,
],
// Computed per request, never persisted: the stack owner is elevated to
// instance admin of their own dedicated instance only while the
// `enableOwnerInstanceAdmin` flag is on. Non-owner stack roles stay
// company-scoped. Turning the flag off de-elevates on the next request —
// there is no role row to clean up.
isInstanceAdmin: await resolveOwnerInstanceAdmin(db, stackRole),
canManageExperimentalSettings: stackRole === "admin",
source: "cloud_tenant",
};
}
function requiredCloudHeader(req: CloudActorHeaderSource, name: string): string {
const value = req.header(name)?.trim();
if (!value) {
throw new Error(`Missing trusted Cloud tenant header ${name}`);
}
return value;
}
function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" {
if (value === "owner" || value === "admin" || value === "member" || value === "support") {
return value;
}
throw new Error("Invalid trusted Cloud tenant stack role");
}
function constantTimeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function cloudTenantCompanyId(stackId: string): string {
return cloudTenantPrimaryCompanyId(stackId);
}
export function humanizeCloudStackSlug(stackId: string): string {
const slug = stackId
.trim()
.replace(/^paperclip-stack-/i, "")
.replace(/^stack-/i, "");
const displayName = slug
.split(/[-_]+/)
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ");
return displayName || "Workspace";
}
export function isKnownBadCloudCompanyName(
name: string,
ids: { companyId: string; paperclipCompanyId?: string },
): boolean {
const normalized = name.trim();
return (
/^paperclip-stack-.+/i.test(normalized) ||
/^stack-.+\s+paperclip$/i.test(normalized) ||
normalized === ids.companyId ||
(ids.paperclipCompanyId !== undefined &&
normalized === ids.paperclipCompanyId)
);
}
async function repairCloudTenantCompanyName(
db: Db,
input: {
companyId: string;
paperclipCompanyId?: string;
paperclipCompanyName: string;
now: Date;
},
): Promise<void> {
try {
const existing = await db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, input.companyId))
.then((rows) => rows[0]);
if (
!existing ||
!isKnownBadCloudCompanyName(existing.name, {
companyId: input.companyId,
paperclipCompanyId: input.paperclipCompanyId,
})
) {
return;
}
await db.transaction(async (tx) => {
const [updated] = await tx
.update(companies)
.set({ name: input.paperclipCompanyName, updatedAt: input.now })
.where(
and(
eq(companies.id, input.companyId),
// A user may rename the company between the read above and this
// repair. Match the exact observed machine name so that concurrent
// genuine renames always win.
eq(companies.name, existing.name),
),
)
.returning({ id: companies.id });
if (!updated) return;
await tx.insert(activityLog).values({
companyId: input.companyId,
actorType: "system",
actorId: "cloud-tenant-auth",
action: "company.updated",
entityType: "company",
entityId: input.companyId,
details: {
source: "cloud_tenant_auth",
reason: "legacy_machine_name_repair",
previousName: existing.name,
name: input.paperclipCompanyName,
},
});
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId },
"Failed to repair legacy Cloud tenant company name",
);
}
}
/**
* Claims the tenant company row for this stack.
*
* The prefix derives from the company name, exactly as it does for a
* self-hosted company. Each attempt is a standalone INSERT, so a failed
* attempt is its own implicit transaction and cannot poison a surrounding
* one. `onConflictDoNothing` only absorbs the `companies.id` conflict — a
* prefix that another company already holds still raises `23505`, so the loop
* moves on to the next suffix.
*/
async function insertCloudTenantCompany(
db: Db,
input: { companyId: string; companyName: string; now: Date },
): Promise<void> {
const base = deriveIssuePrefixBase(input.companyName);
for (let attempt = 1; attempt <= MAX_ISSUE_PREFIX_ATTEMPTS; attempt += 1) {
try {
await db
.insert(companies)
.values({
id: input.companyId,
name: input.companyName,
description: null,
status: "active",
issuePrefix: `${base}${issuePrefixSuffixForAttempt(attempt)}`,
updatedAt: input.now,
})
.onConflictDoNothing({
target: companies.id,
});
return;
} catch (error) {
if (!isIssuePrefixConflict(error)) throw error;
}
}
throw new Error("Unable to allocate a unique issue prefix for the tenant company");
}
/**
* The issue prefix that pre-name-derivation builds gave a tenant company.
*
* This derivation survives only as the detector for the one-time repair
* below. Nothing mints a prefix this way any more.
*/
function legacyProvisionedIssuePrefix(stackId: string): string {
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
return `PC${hash}`;
}
/** The placeholder description that pre-name-derivation builds wrote. */
const LEGACY_PROVISIONED_DESCRIPTION_PREFIX = "Provisioned by Paperclip Cloud for stack ";
/**
* One-time repair for companies claimed by a pre-name-derivation build.
*
* Those companies carry an opaque hash prefix and a placeholder description
* that the operator never chose. Re-derive the prefix from the company's
* current name, re-key the stored issue and case identifiers onto it, and drop
* the placeholder. Both guards stop matching once the repair lands, so a later
* pass is a no-op.
*/
async function repairCloudTenantCompanyProvisionDefaults(
db: Db,
input: { companyId: string; stackId: string; now: Date },
): Promise<void> {
try {
const existing = await db
.select({
name: companies.name,
issuePrefix: companies.issuePrefix,
description: companies.description,
})
.from(companies)
.where(eq(companies.id, input.companyId))
.then((rows) => rows[0]);
if (!existing) return;
const legacyPrefix = legacyProvisionedIssuePrefix(input.stackId);
const legacyDescription = existing.description?.startsWith(LEGACY_PROVISIONED_DESCRIPTION_PREFIX)
? existing.description
: null;
if (existing.issuePrefix !== legacyPrefix) {
if (!legacyDescription) return;
// The prefix was already re-derived, so only the placeholder is left.
await db
.update(companies)
.set({ description: null, updatedAt: input.now })
.where(and(
eq(companies.id, input.companyId),
eq(companies.description, legacyDescription),
));
return;
}
await db.transaction(async (tx) => {
const candidate = await pickAvailableIssuePrefix(tx, deriveIssuePrefixBase(existing.name));
if (!candidate || candidate === legacyPrefix) return;
const [updated] = await tx
.update(companies)
.set({
issuePrefix: candidate,
...(legacyDescription ? { description: null } : {}),
updatedAt: input.now,
})
.where(and(
eq(companies.id, input.companyId),
// A user may rename the company between the read above and this
// repair. Match the exact observed legacy prefix so that concurrent
// genuine renames always win.
eq(companies.issuePrefix, legacyPrefix),
))
.returning({ id: companies.id });
if (!updated) return;
const rekeyed = await rekeyCompanyIssueIdentifiers(tx, {
companyId: input.companyId,
fromPrefix: legacyPrefix,
toPrefix: candidate,
});
await tx.insert(activityLog).values({
companyId: input.companyId,
actorType: "system",
actorId: "cloud-tenant-auth",
action: "company.updated",
entityType: "company",
entityId: input.companyId,
details: {
source: "cloud_tenant_auth",
reason: "legacy_provision_defaults_repair",
previousIssuePrefix: legacyPrefix,
issuePrefix: candidate,
descriptionCleared: legacyDescription !== null,
issuesRekeyed: rekeyed.issues,
casesRekeyed: rekeyed.cases,
},
});
});
} catch (err) {
logger.warn(
{ err, companyId: input.companyId },
"Failed to repair legacy tenant company provisioning defaults",
);
}
}
export function requireBoard(req: Express.Request) {
return req.actor.type === "board";
}