mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected chats show safe notices when a run starts, waits, finishes, or fails. > - A recurring scan projected these notices even when no eligible work existed. > - Eligibility depends on committed run state, conversation lineage, and current authority. > - Existing database rows can reconstruct that work after restart. > - This PR wakes a scoped worker from those writes and removes lifecycle polling. > - Native progress events keep their separate existing owner until the next migration. ## Linked Issues or Issue Description **What happened?** The chat maintenance timer repeatedly scanned run lifecycle state. Empty and settled instances still queried the database. A run-status event alone cannot replace the scan: prompt receipts, continuation receipts, identity changes, and restored authority can make a run eligible later. **What did you expect?** Committed changes wake projection. Startup reconstructs eligible work. A completed pass stops until another relevant change arrives. Failed work retains recovery without scanning settled history forever. **Steps to reproduce** Configure an automatic chat endpoint. Finish its work, or leave it unused. Observe recurring lifecycle projection queries from the chat maintenance callback. Stacked on #17592. Related foundation: #15625. This PR owns lifecycle milestones only. ## What Changed - Preserve upstream coalesced Cloud membership writes together with locked membership restoration and its committed chat notification. - Signal milestone eligibility from the real Slack account-link transaction. - Register the lifecycle worker with the existing delivery coordinator. Remove lifecycle projection from the periodic chat callback. Keep native progress in a separately named lane and preserve the explicit combined API. - Reconstruct company/issue scopes from the existing selector. Drain bounded batches with company fairness. Retry failures, retain writes through drain/resume, and disarm settled work. - Signal from supported run, presentation, interaction, receipt, lineage, conversation, recovery, and authority writers. Preserve their transaction ownership, including nested savepoints and uncertain settlement. No new table is added. - Preserve provider authorization, authored-final suppression, responsible-user continuity, deduplication, the 24-hour selection window, and raw-output restrictions. Keep provider interpretation in its owner. - Preserve PostgreSQL timestamp precision in lifecycle pagination. Rejected submillisecond candidates must not repeat the same page and starve later notices. - Document producer coverage and alternate-writer dispositions in `doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`. ## Verification Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting master after #17592 merged at `7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates the actual landed parent. The entire 77-path milestone patch is binary-identical to the locally verified preparation across that ancestry reconciliation; both unrelated master paths are preserved. The four earlier producer/test integration conflicts retain scoped milestone and voice notices, current onboarding completion admission, and expanded identity query/read-scope coverage. - **330 distinct selected cases pass on the verified unchanged patch:** 305 across 14 complete suites (all five milestone worker suites, run identity, board claim, Cloud provisioning, auth session routes, Cloud actor authentication, stale-lock recovery, GitHub credentials, project Git authorization, and app admission wiring), plus 25 selected real chat producer cases. The remaining 1,590 chat integration cases were skipped, not passed. - The real producer cases cover saved milestone discovery, GitHub and Slack identity changes, account-link rollback/reconfirmation, ordinary ingress reactivation, post-close action/delivery receipt settlement, all ten authority-restoration paths including Cloud synchronization, and changed native review metadata. - Worker coverage verifies outer commit, rollback, uncertain-write recovery, restart, bounded batching/fairness, timestamp precision, retries, drain/resume, and settled SQL quiet. Authorization and existing publication deduplication remain with their owning services. - Server production typechecking, build, and module-boundary checks pass. The tests above were executed separately. Production typechecking excludes `src/__tests__` and does not replace those runs. Each database owner uses its own newly created and app-migrated PostgreSQL fixture. - [Current-parent acceptance and preserved failure ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28) records commands, counts, producer scope, conflict dispositions, and limits. Earlier combined-stack counts and failures keep their original revision scope; local acceptance does not erase old CI failures. - No new migration, queue table, dependency, workflow, or shared scheduler contract is introduced. Parent migration 0397, bounded consent recovery, voice cleanup/retention repairs, and runner checkpoint restoration remain present. - **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No Ponytail review is present, which is an explicitly authorized nonblocker; no failing review is waived. Older reviews and parent checks are not current passes. Actual failing Ponytail findings must be fixed; an absent/stopped/paused turn without a verdict is an authorized nonblocker. Active unfinished Ponytail uses its original request/start timer, with a 25-minute monitor cutoff respecting the user's 30-minute maximum. - No complete local repository matrix, live provider tenant, external alarm, physical host/database suspension, or fleet savings claim is made. Native progress remains a separate following slice. ## Risks - A future eligibility writer must notify the existing projection boundary. Independent SQL writers or another active server process are outside this process-local signal contract. - Authority and lineage remain sensitive. The worker uses the existing selectors and publication policy; it does not grant execution or disclosure authority. - Native progress and other publication maintenance still have recurring work. This PR removes one reason, not the entire chat maintenance timer. - Worker quiet does not prove database suspension, host sleep, or external alarm coverage. Cloud sleep blockers remain unchanged. - Merge eligibility requires passing current-head CI and Greptile 5/5 after this actual-master reconciliation. Subsequent stack slices retain their own gates. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context-window size are not exposed. Used repository inspection, reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details available in this session) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run the documented focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1222 lines
45 KiB
TypeScript
1222 lines
45 KiB
TypeScript
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
|
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
|
|
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
|
|
import { notifyToolConnectionHealth } from "./tool-connection-health-notifications.js";
|
|
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
companyMemberships,
|
|
companySecretBindings,
|
|
companySecrets,
|
|
connectionGrantDelegations,
|
|
connectionGrantMembers,
|
|
connectionGrants,
|
|
instanceUserRoles,
|
|
issues,
|
|
principalPermissionGrants,
|
|
toolAccessAuditEvents,
|
|
toolConnections,
|
|
userSecretDeclarations,
|
|
} from "@paperclipai/db";
|
|
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
|
import { conflict } from "../errors.js";
|
|
import { assertAssignableAgent } from "./agent-assignability.js";
|
|
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
|
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
|
|
|
|
type MembershipRow = typeof companyMemberships.$inferSelect;
|
|
type GrantInput = {
|
|
permissionKey: PermissionKey;
|
|
scope?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type MemberArchiveInput = {
|
|
reassignment?: {
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
} | null;
|
|
};
|
|
|
|
export function accessService(db: Db) {
|
|
const authorization = authorizationService(db);
|
|
|
|
async function memberAccessTransaction<T>(operation: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => Promise<T>): Promise<T> {
|
|
for (let attempt = 0; ; attempt++) {
|
|
try { return await db.transaction(operation); }
|
|
catch (error) {
|
|
const failure = error as { code?: string; cause?: { code?: string } };
|
|
if ((failure?.cause?.code ?? failure?.code) !== "55P03") throw error;
|
|
if (attempt >= 2) throw conflict("Member access changed during cleanup. Try again.", { retryable: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sweepMemberConnectionAccess(
|
|
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
|
|
companyId: string,
|
|
userId: string,
|
|
now: Date,
|
|
) {
|
|
const departingAudienceRows = await tx.select({
|
|
grantId: connectionGrantMembers.grantId,
|
|
}).from(connectionGrantMembers).where(and(
|
|
eq(connectionGrantMembers.companyId, companyId),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
eq(connectionGrantMembers.subjectId, userId),
|
|
));
|
|
const departingAudienceGrantIds = [...new Set(departingAudienceRows.map((row) => row.grantId))];
|
|
const affectedAudienceRows = departingAudienceGrantIds.length === 0 ? [] : await tx.select({
|
|
grantId: connectionGrantMembers.grantId,
|
|
subjectId: connectionGrantMembers.subjectId,
|
|
}).from(connectionGrantMembers).where(and(
|
|
eq(connectionGrantMembers.companyId, companyId),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
inArray(connectionGrantMembers.grantId, departingAudienceGrantIds),
|
|
));
|
|
const activeOrganizationAudienceGrants = departingAudienceGrantIds.length === 0 ? [] : await tx.select({
|
|
id: connectionGrants.id,
|
|
}).from(connectionGrants).where(and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
eq(connectionGrants.kind, "organization"),
|
|
eq(connectionGrants.status, "active"),
|
|
inArray(connectionGrants.id, departingAudienceGrantIds),
|
|
));
|
|
const activeOrganizationAudienceGrantIds = new Set(activeOrganizationAudienceGrants.map((grant) => grant.id));
|
|
const soleAudienceGrantIds = new Set(departingAudienceGrantIds.filter((grantId) =>
|
|
activeOrganizationAudienceGrantIds.has(grantId)
|
|
&& affectedAudienceRows.filter((row) => row.grantId === grantId).length === 1,
|
|
));
|
|
|
|
const ownedGrants = await tx.select({
|
|
id: connectionGrants.id,
|
|
connectionId: connectionGrants.connectionId,
|
|
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
|
}).from(connectionGrants).where(and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, userId),
|
|
));
|
|
const grantIds = ownedGrants.map((grant) => grant.id);
|
|
const ownedGrantIds = new Set(grantIds);
|
|
const affectedConnectionIds = [...new Set(ownedGrants.map((grant) => grant.connectionId))];
|
|
const affectedConnections = new Set(affectedConnectionIds);
|
|
// Membership removal revokes personal connection identities. It must not
|
|
// erase unrelated user-scoped values used by agent or environment secret
|
|
// declarations or bindings. Start with only secrets the departing user's
|
|
// grants explicitly reference, then fail toward retention whenever another
|
|
// consumer still names the secret or its user-secret definition.
|
|
const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) =>
|
|
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
|
))];
|
|
const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({
|
|
id: companySecrets.id,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
}).from(companySecrets).where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.ownerUserId, userId),
|
|
inArray(companySecrets.id, referencedSecretIds),
|
|
));
|
|
const ownedSecretIds = ownedSecrets.map((secret) => secret.id);
|
|
const ownedSecretSet = new Set(ownedSecretIds);
|
|
const retainedSecretIds = new Set<string>();
|
|
const sharedConnectionSecretIds = new Set<string>();
|
|
let grantMemberRefs: Array<{ grantId: string; subjectId: string }> = [];
|
|
let existingMemberUserIds = new Set<string>();
|
|
let grantRefs: Array<{
|
|
id: string;
|
|
connectionId: string;
|
|
kind: typeof connectionGrants.$inferSelect.kind;
|
|
status: typeof connectionGrants.$inferSelect.status;
|
|
credentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs;
|
|
}> = [];
|
|
let connectionRefs: Array<{
|
|
id: string;
|
|
transport: typeof toolConnections.$inferSelect.transport;
|
|
credentialRefs: typeof toolConnections.$inferSelect.credentialRefs;
|
|
credentialSecretRefs: typeof toolConnections.$inferSelect.credentialSecretRefs;
|
|
}> = [];
|
|
if (ownedSecretIds.length > 0) {
|
|
const definitionIds = ownedSecrets.flatMap((secret) =>
|
|
secret.userSecretDefinitionId ? [secret.userSecretDefinitionId] : [],
|
|
);
|
|
const [
|
|
bindingRefs,
|
|
declarationRefs,
|
|
allGrantRefs,
|
|
allConnectionRefs,
|
|
allGrantMemberRefs,
|
|
membershipRefs,
|
|
] = await Promise.all([
|
|
tx.select({
|
|
secretId: companySecretBindings.secretId,
|
|
targetType: companySecretBindings.targetType,
|
|
targetId: companySecretBindings.targetId,
|
|
}).from(companySecretBindings).where(and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
inArray(companySecretBindings.secretId, ownedSecretIds),
|
|
)),
|
|
definitionIds.length === 0 ? Promise.resolve([]) : tx.select({
|
|
userSecretDefinitionId: userSecretDeclarations.userSecretDefinitionId,
|
|
}).from(userSecretDeclarations).where(and(
|
|
eq(userSecretDeclarations.companyId, companyId),
|
|
inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds),
|
|
)),
|
|
tx.select({
|
|
id: connectionGrants.id,
|
|
connectionId: connectionGrants.connectionId,
|
|
kind: connectionGrants.kind,
|
|
status: connectionGrants.status,
|
|
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
|
}).from(connectionGrants).where(eq(connectionGrants.companyId, companyId)),
|
|
tx.select({
|
|
id: toolConnections.id,
|
|
transport: toolConnections.transport,
|
|
credentialRefs: toolConnections.credentialRefs,
|
|
credentialSecretRefs: toolConnections.credentialSecretRefs,
|
|
}).from(toolConnections).where(eq(toolConnections.companyId, companyId)),
|
|
tx.select({
|
|
grantId: connectionGrantMembers.grantId,
|
|
subjectId: connectionGrantMembers.subjectId,
|
|
}).from(connectionGrantMembers).where(and(
|
|
eq(connectionGrantMembers.companyId, companyId),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
)),
|
|
tx.select({ userId: companyMemberships.principalId }).from(companyMemberships).where(and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
ne(companyMemberships.principalId, userId),
|
|
)),
|
|
]);
|
|
grantRefs = allGrantRefs;
|
|
connectionRefs = allConnectionRefs;
|
|
grantMemberRefs = allGrantMemberRefs;
|
|
existingMemberUserIds = new Set(membershipRefs.map((row) => row.userId));
|
|
|
|
for (const binding of bindingRefs) {
|
|
if (binding.targetType !== "tool_connection" || !affectedConnections.has(binding.targetId)) {
|
|
retainedSecretIds.add(binding.secretId);
|
|
}
|
|
}
|
|
const declaredDefinitions = new Set(declarationRefs.map((row) => row.userSecretDefinitionId));
|
|
for (const secret of ownedSecrets) {
|
|
if (secret.userSecretDefinitionId && declaredDefinitions.has(secret.userSecretDefinitionId)) {
|
|
retainedSecretIds.add(secret.id);
|
|
}
|
|
}
|
|
for (const grant of grantRefs) {
|
|
if (ownedGrantIds.has(grant.id)) continue;
|
|
const grantAudience = grantMemberRefs.filter((member) => member.grantId === grant.id);
|
|
const remainingGrantAudience = grantAudience.filter((member) => member.subjectId !== userId);
|
|
const hasSurvivingOrganizationAudience = grant.kind === "organization"
|
|
&& grant.status === "active"
|
|
&& (
|
|
soleAudienceGrantIds.has(grant.id)
|
|
? true
|
|
: remainingGrantAudience.length === 0
|
|
? existingMemberUserIds.size > 0
|
|
: remainingGrantAudience.some((member) => existingMemberUserIds.has(member.subjectId))
|
|
);
|
|
for (const ref of grant.credentialSecretRefs) {
|
|
if (!ownedSecretSet.has(ref.secretId)) continue;
|
|
if (!affectedConnections.has(grant.connectionId)) {
|
|
retainedSecretIds.add(ref.secretId);
|
|
} else if (grant.kind === "user" || hasSurvivingOrganizationAudience) {
|
|
// A connection may temporarily carry separate user grants that
|
|
// reference the same credential, or an organization grant may
|
|
// still have another persisted audience member. A sole named
|
|
// audience row stays persisted instead of being widened to company
|
|
// scope; both resolvers require current active membership, so the
|
|
// row remains dormant until company access is restored. Pending,
|
|
// suspended, and archived memberships are intentionally included
|
|
// because company access can reactivate each of them later.
|
|
retainedSecretIds.add(ref.secretId);
|
|
sharedConnectionSecretIds.add(ref.secretId);
|
|
}
|
|
}
|
|
}
|
|
for (const connection of connectionRefs) {
|
|
if (affectedConnections.has(connection.id)) continue;
|
|
for (const ref of [...connection.credentialRefs, ...connection.credentialSecretRefs]) {
|
|
if (ownedSecretSet.has(ref.secretId)) retainedSecretIds.add(ref.secretId);
|
|
}
|
|
}
|
|
}
|
|
const secretIdsToDelete = ownedSecretIds.filter((secretId) => !retainedSecretIds.has(secretId));
|
|
const connectionSecretIdsToRemove = new Set(
|
|
ownedSecretIds.filter((secretId) => !sharedConnectionSecretIds.has(secretId)),
|
|
);
|
|
const affectedGrantIds = [...new Set([...grantIds, ...grantRefs
|
|
.filter(grant => affectedConnections.has(grant.connectionId))
|
|
.map(grant => grant.id)])];
|
|
if (affectedGrantIds.length > 0) {
|
|
await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
|
.where(and(eq(connectionGrants.companyId, companyId), inArray(connectionGrants.id, affectedGrantIds)))
|
|
.orderBy(connectionGrants.id).for("update");
|
|
}
|
|
if (affectedConnectionIds.length > 0) {
|
|
await tx.select({ id: toolConnections.id }).from(toolConnections)
|
|
.where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, affectedConnectionIds)))
|
|
.orderBy(toolConnections.id).for("update");
|
|
}
|
|
if (secretIdsToDelete.length > 0) {
|
|
await tx.select({ id: companySecrets.id }).from(companySecrets)
|
|
.where(and(eq(companySecrets.companyId, companyId), inArray(companySecrets.id, secretIdsToDelete)))
|
|
.orderBy(companySecrets.id).for("update", { noWait: true });
|
|
}
|
|
await tx.select({ id: agents.id }).from(agents)
|
|
.where(and(eq(agents.companyId, companyId), notInArray(agents.status, ["pending_approval", "terminated"])))
|
|
.orderBy(agents.id).for("no key update");
|
|
const removedDelegations = grantIds.length === 0 ? [] : await tx
|
|
.delete(connectionGrantDelegations)
|
|
.where(and(
|
|
eq(connectionGrantDelegations.companyId, companyId),
|
|
inArray(connectionGrantDelegations.grantId, grantIds),
|
|
))
|
|
.returning();
|
|
if (grantIds.length > 0) {
|
|
await tx.update(connectionGrants).set({
|
|
status: "revoked",
|
|
isDefault: false,
|
|
revokedAt: now,
|
|
revokedByUserId: null,
|
|
revokedByAgentId: null,
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(connectionGrants.companyId, companyId),
|
|
inArray(connectionGrants.id, grantIds),
|
|
));
|
|
}
|
|
if (ownedSecretIds.length > 0) {
|
|
for (const grant of grantRefs) {
|
|
if (!ownedGrantIds.has(grant.id) && !affectedConnections.has(grant.connectionId)) continue;
|
|
const refsToRemove = ownedGrantIds.has(grant.id) ? ownedSecretSet : connectionSecretIdsToRemove;
|
|
const credentialSecretRefs = grant.credentialSecretRefs.filter(
|
|
(ref) => !refsToRemove.has(ref.secretId),
|
|
);
|
|
if (credentialSecretRefs.length !== grant.credentialSecretRefs.length) {
|
|
await tx.update(connectionGrants).set({
|
|
credentialSecretRefs,
|
|
...(ownedGrantIds.has(grant.id) || grant.status === "revoked" ? {
|
|
status: "revoked" as const,
|
|
isDefault: false,
|
|
} : {
|
|
status: "needs_reauthorization" as const,
|
|
isDefault: false,
|
|
}),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
}
|
|
}
|
|
for (const connection of connectionRefs) {
|
|
if (!affectedConnections.has(connection.id)) continue;
|
|
const credentialRefs = connection.credentialRefs.filter(
|
|
(ref) => !connectionSecretIdsToRemove.has(ref.secretId),
|
|
);
|
|
const credentialSecretRefs = connection.credentialSecretRefs.filter(
|
|
(ref) => !connectionSecretIdsToRemove.has(ref.secretId),
|
|
);
|
|
if (
|
|
credentialRefs.length !== connection.credentialRefs.length ||
|
|
credentialSecretRefs.length !== connection.credentialSecretRefs.length
|
|
) {
|
|
const hasUnaffectedActiveGrant = grantRefs.some((grant) =>
|
|
grant.connectionId === connection.id
|
|
&& !ownedGrantIds.has(grant.id)
|
|
&& grant.status === "active"
|
|
&& grant.credentialSecretRefs.length > 0
|
|
&& grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)),
|
|
);
|
|
if (connection.transport === "chat_sdk") await notifyChatEndpointWork(tx, connection.id, companyId);
|
|
if (connection.transport === "voice") await notifyVoiceChange(tx, { kind: "connection", id: connection.id });
|
|
if (!hasUnaffectedActiveGrant) await notifyToolConnectionHealth(tx, { companyId, id: connection.id });
|
|
await tx.update(toolConnections).set({
|
|
credentialRefs,
|
|
credentialSecretRefs,
|
|
...(!hasUnaffectedActiveGrant ? {
|
|
status: "draft" as const,
|
|
enabled: false,
|
|
healthStatus: "missing_secret" as const,
|
|
healthMessage: "Personal credential owner no longer has company access. Reauthorize this connection.",
|
|
lastError: "oauth_reauthorization_required",
|
|
} : {}),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(toolConnections.id, connection.id));
|
|
}
|
|
}
|
|
if (affectedConnectionIds.length > 0 && connectionSecretIdsToRemove.size > 0) {
|
|
await tx.delete(companySecretBindings).where(and(
|
|
eq(companySecretBindings.companyId, companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
inArray(companySecretBindings.targetId, affectedConnectionIds),
|
|
inArray(companySecretBindings.secretId, [...connectionSecretIdsToRemove]),
|
|
));
|
|
}
|
|
if (secretIdsToDelete.length > 0) {
|
|
await tx.delete(companySecrets).where(and(
|
|
eq(companySecrets.companyId, companyId),
|
|
inArray(companySecrets.id, secretIdsToDelete),
|
|
));
|
|
}
|
|
}
|
|
await tx.delete(connectionGrantMembers).where(and(
|
|
eq(connectionGrantMembers.companyId, companyId),
|
|
eq(connectionGrantMembers.subjectType, "user"),
|
|
eq(connectionGrantMembers.subjectId, userId),
|
|
soleAudienceGrantIds.size > 0
|
|
? notInArray(connectionGrantMembers.grantId, [...soleAudienceGrantIds])
|
|
: undefined,
|
|
));
|
|
if (removedDelegations.length > 0) {
|
|
const connectionByGrant = new Map(ownedGrants.map((grant) => [grant.id, grant.connectionId]));
|
|
await tx.insert(toolAccessAuditEvents).values(removedDelegations.map((delegation) => ({
|
|
companyId,
|
|
connectionId: connectionByGrant.get(delegation.grantId) ?? null,
|
|
actorType: "system",
|
|
actorId: null,
|
|
action: "connection_grant.delegation_revoked",
|
|
outcome: "success",
|
|
reasonCode: "membership_removed",
|
|
details: {
|
|
grantId: delegation.grantId,
|
|
delegationId: delegation.id,
|
|
agentId: delegation.agentId,
|
|
ownerUserId: userId,
|
|
},
|
|
})));
|
|
}
|
|
}
|
|
|
|
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
|
if (!userId) return false;
|
|
const row = await db
|
|
.select({ id: instanceUserRoles.id })
|
|
.from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.then((rows) => rows[0] ?? null);
|
|
return Boolean(row);
|
|
}
|
|
|
|
async function getMembership(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
): Promise<MembershipRow | null> {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, principalType),
|
|
eq(companyMemberships.principalId, principalId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function hasPermission(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
permissionKey: PermissionKey,
|
|
): Promise<boolean> {
|
|
return authorization.decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
permissionKey,
|
|
action: permissionKey,
|
|
}).then((decision) => decision.allowed);
|
|
}
|
|
|
|
async function canUser(
|
|
companyId: string,
|
|
userId: string | null | undefined,
|
|
permissionKey: PermissionKey,
|
|
): Promise<boolean> {
|
|
return authorization.decide({
|
|
actor: { type: "board", userId },
|
|
action: permissionKey,
|
|
resource: { type: "company", companyId },
|
|
}).then((decision) => decision.allowed);
|
|
}
|
|
|
|
async function decide(input: {
|
|
actor: AuthorizationActor;
|
|
action: Parameters<typeof authorization.decide>[0]["action"];
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
}) {
|
|
return authorization.decide(input);
|
|
}
|
|
|
|
async function listMembers(companyId: string) {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(eq(companyMemberships.companyId, companyId))
|
|
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
|
}
|
|
|
|
async function getMemberById(companyId: string, memberId: string) {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function listActiveUserMemberships(companyId: string) {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.orderBy(sql`${companyMemberships.createdAt} asc`);
|
|
}
|
|
|
|
async function setMemberPermissions(
|
|
companyId: string,
|
|
memberId: string,
|
|
grants: GrantInput[],
|
|
grantedByUserId: string | null,
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
const member = await tx.select().from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
|
.for("update").then(rows => rows[0] ?? null);
|
|
if (!member) return null;
|
|
await tx
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, member.principalType),
|
|
eq(principalPermissionGrants.principalId, member.principalId),
|
|
),
|
|
);
|
|
if (grants.length > 0) {
|
|
await tx.insert(principalPermissionGrants).values(
|
|
grants.map((grant) => ({
|
|
companyId,
|
|
principalType: member.principalType,
|
|
principalId: member.principalId,
|
|
permissionKey: grant.permissionKey,
|
|
scope: grant.scope ?? null,
|
|
grantedByUserId,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})),
|
|
);
|
|
}
|
|
return member;
|
|
});
|
|
}
|
|
|
|
async function updateMemberAndPermissions(
|
|
companyId: string,
|
|
memberId: string,
|
|
data: {
|
|
membershipRole?: string | null;
|
|
status?: "pending" | "active" | "suspended";
|
|
grants: GrantInput[];
|
|
},
|
|
grantedByUserId: string | null,
|
|
) {
|
|
return memberAccessTransaction(async (tx) => {
|
|
await tx.execute(sql`
|
|
select ${companyMemberships.id}
|
|
from ${companyMemberships}
|
|
where ${companyMemberships.companyId} = ${companyId}
|
|
and ${companyMemberships.principalType} = 'user'
|
|
and ${companyMemberships.status} = 'active'
|
|
and ${companyMemberships.membershipRole} = 'owner'
|
|
for update
|
|
`);
|
|
|
|
const existing = await tx
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const nextMembershipRole =
|
|
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
|
|
const nextStatus = data.status ?? existing.status;
|
|
|
|
if (existing.principalType === "user" && nextStatus === "active" && nextMembershipRole !== "viewer"
|
|
&& (existing.status !== "active" || existing.membershipRole === "viewer")) {
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
}
|
|
|
|
if (
|
|
existing.principalType === "user" &&
|
|
existing.status === "active" &&
|
|
existing.membershipRole === "owner" &&
|
|
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
|
) {
|
|
const activeOwnerCount = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
eq(companyMemberships.membershipRole, "owner"),
|
|
),
|
|
)
|
|
.then((rows) => rows.length);
|
|
if (activeOwnerCount <= 1) {
|
|
throw conflict("Cannot remove the last active owner");
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
if (existing.status === "active" && nextStatus !== "active" && existing.principalType === "user") {
|
|
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
|
}
|
|
const updated = await tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
membershipRole: nextMembershipRole,
|
|
status: nextStatus,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(companyMemberships.id, existing.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? existing);
|
|
|
|
await tx
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, existing.principalType),
|
|
eq(principalPermissionGrants.principalId, existing.principalId),
|
|
),
|
|
);
|
|
if (data.grants.length > 0) {
|
|
await tx.insert(principalPermissionGrants).values(
|
|
data.grants.map((grant) => ({
|
|
companyId,
|
|
principalType: existing.principalType,
|
|
principalId: existing.principalId,
|
|
permissionKey: grant.permissionKey,
|
|
scope: grant.scope ?? null,
|
|
grantedByUserId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})),
|
|
);
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
async function assertCanRemoveActiveOwner(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
status: string,
|
|
membershipRole: string | null,
|
|
tx: Pick<Db, "select">,
|
|
) {
|
|
if (
|
|
principalType !== "user" ||
|
|
status !== "active" ||
|
|
membershipRole !== "owner"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const activeOwnerCount = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
eq(companyMemberships.membershipRole, "owner"),
|
|
),
|
|
)
|
|
.then((rows) => rows.length);
|
|
if (activeOwnerCount <= 1) {
|
|
throw conflict("Cannot remove the last active owner");
|
|
}
|
|
}
|
|
|
|
async function assertAssignableArchiveTarget(
|
|
companyId: string,
|
|
input: MemberArchiveInput["reassignment"],
|
|
tx: Pick<Db, "select">,
|
|
) {
|
|
if (!input?.assigneeAgentId && !input?.assigneeUserId) return;
|
|
if (input.assigneeAgentId && input.assigneeUserId) {
|
|
throw conflict("Choose either an agent or user reassignment target");
|
|
}
|
|
if (input.assigneeUserId) {
|
|
const membership = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, input.assigneeUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!membership) {
|
|
throw conflict("Replacement user must be an active company member");
|
|
}
|
|
return;
|
|
}
|
|
|
|
await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" });
|
|
}
|
|
|
|
async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) {
|
|
return memberAccessTransaction(async (tx) => {
|
|
await tx.execute(sql`
|
|
select ${companyMemberships.id}
|
|
from ${companyMemberships}
|
|
where ${companyMemberships.companyId} = ${companyId}
|
|
and ${companyMemberships.principalType} = 'user'
|
|
and ${companyMemberships.status} = 'active'
|
|
and ${companyMemberships.membershipRole} = 'owner'
|
|
for update
|
|
`);
|
|
|
|
const existing = await tx
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
if (existing.principalType !== "user") {
|
|
throw conflict("Only human company members can be archived");
|
|
}
|
|
if (existing.status === "archived") {
|
|
return { member: existing, reassignedIssueCount: 0 };
|
|
}
|
|
if (input.reassignment?.assigneeUserId === existing.principalId) {
|
|
throw conflict("Replacement user cannot be the archived member");
|
|
}
|
|
|
|
await assertCanRemoveActiveOwner(
|
|
companyId,
|
|
existing.principalType,
|
|
existing.status,
|
|
existing.membershipRole,
|
|
tx,
|
|
);
|
|
await assertAssignableArchiveTarget(companyId, input.reassignment, tx);
|
|
|
|
const now = new Date();
|
|
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
|
const assignmentPatch = {
|
|
assigneeAgentId: input.reassignment?.assigneeAgentId ?? null,
|
|
assigneeUserId: input.reassignment?.assigneeUserId ?? null,
|
|
updatedAt: now,
|
|
};
|
|
const assignedOpenIssueWhere = and(
|
|
eq(issues.companyId, companyId),
|
|
eq(issues.assigneeUserId, existing.principalId),
|
|
sql`${issues.status} not in ('done', 'cancelled')`,
|
|
);
|
|
const resetInProgress = await tx
|
|
.update(issues)
|
|
.set({
|
|
...assignmentPatch,
|
|
status: "todo",
|
|
startedAt: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionLockedAt: null,
|
|
})
|
|
.where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress")))
|
|
.returning({ id: issues.id });
|
|
const reassigned = await tx
|
|
.update(issues)
|
|
.set(assignmentPatch)
|
|
.where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress")))
|
|
.returning({ id: issues.id });
|
|
|
|
await tx
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, existing.principalType),
|
|
eq(principalPermissionGrants.principalId, existing.principalId),
|
|
),
|
|
);
|
|
|
|
const archived = await tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
status: "archived",
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(companyMemberships.id, existing.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? existing);
|
|
|
|
return {
|
|
member: archived,
|
|
reassignedIssueCount: resetInProgress.length + reassigned.length,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function promoteInstanceAdmin(userId: string) {
|
|
const existing = await db
|
|
.select()
|
|
.from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existing) return existing;
|
|
return db
|
|
.insert(instanceUserRoles)
|
|
.values({
|
|
userId,
|
|
role: "instance_admin",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
}
|
|
|
|
async function demoteInstanceAdmin(userId: string) {
|
|
return db
|
|
.delete(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function listUserCompanyAccess(userId: string) {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId)))
|
|
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
|
}
|
|
|
|
async function setUserCompanyAccess(
|
|
userId: string,
|
|
companyIds: string[],
|
|
options: { actorUserId?: string | null } = {},
|
|
) {
|
|
const target = new Set(companyIds);
|
|
|
|
await memberAccessTransaction(async (tx) => {
|
|
// Serialize every company-access removal/reactivation with personal OAuth
|
|
// completion, which locks the same membership row before writing secrets.
|
|
const existing = await tx
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId)))
|
|
.orderBy(companyMemberships.companyId, companyMemberships.id)
|
|
.for("update");
|
|
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
|
const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived");
|
|
if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) {
|
|
throw conflict("You cannot remove yourself");
|
|
}
|
|
if (toArchive.length > 0 && (await isInstanceAdmin(userId))) {
|
|
throw conflict("Instance admins cannot be removed from company access");
|
|
}
|
|
const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin");
|
|
if (protectedArchives.length > 0) {
|
|
throw conflict("Owners and admins cannot be removed from company access");
|
|
}
|
|
const activeOwnerArchives = toArchive.filter(
|
|
(row) => row.status === "active" && row.membershipRole === "owner",
|
|
);
|
|
if (activeOwnerArchives.length > 0) {
|
|
const activeOwnerRows = await tx
|
|
.select({ companyId: companyMemberships.companyId, id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
eq(companyMemberships.membershipRole, "owner"),
|
|
inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)),
|
|
),
|
|
);
|
|
for (const row of activeOwnerArchives) {
|
|
const remainingOwners =
|
|
activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1;
|
|
if (remainingOwners <= 0) {
|
|
throw conflict("Cannot remove the last active owner");
|
|
}
|
|
}
|
|
}
|
|
if (toArchive.length > 0) {
|
|
const now = new Date();
|
|
for (const membership of toArchive) {
|
|
await sweepMemberConnectionAccess(tx, membership.companyId, membership.principalId, now);
|
|
}
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({ status: "archived", updatedAt: now })
|
|
.where(inArray(companyMemberships.id, toArchive.map((row) => row.id)));
|
|
await tx
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(principalPermissionGrants.principalId, userId),
|
|
inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const companyId of target) {
|
|
const existingMembership = existingByCompany.get(companyId);
|
|
if (existingMembership) {
|
|
if (existingMembership.status !== "active") {
|
|
if (existingMembership.membershipRole !== "viewer") await notifyChatRunMilestones(tx, { companyId });
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
status: "active",
|
|
membershipRole: existingMembership.membershipRole ?? "operator",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companyMemberships.id, existingMembership.id));
|
|
}
|
|
continue;
|
|
}
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
await tx.insert(companyMemberships).values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole: "operator",
|
|
});
|
|
}
|
|
});
|
|
|
|
return listUserCompanyAccess(userId);
|
|
}
|
|
|
|
async function ensureMembership(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
membershipRole: string | null = "member",
|
|
status: "pending" | "active" | "suspended" = "active",
|
|
) {
|
|
return db.transaction(async tx => {
|
|
const existing = await tx.select().from(companyMemberships).where(and(
|
|
eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, principalType),
|
|
eq(companyMemberships.principalId, principalId),
|
|
)).for("update").then(rows => rows[0] ?? null);
|
|
if (existing) {
|
|
if (existing.status !== status || existing.membershipRole !== membershipRole) {
|
|
if (principalType === "user" && status === "active" && membershipRole !== "viewer"
|
|
&& (existing.status !== "active" || existing.membershipRole === "viewer")) {
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
}
|
|
const updated = await tx
|
|
.update(companyMemberships)
|
|
.set({ status, membershipRole, updatedAt: new Date() })
|
|
.where(eq(companyMemberships.id, existing.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
return updated ?? existing;
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
if (principalType === "user" && status === "active" && membershipRole !== "viewer") {
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
}
|
|
return tx
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
status,
|
|
membershipRole,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
});
|
|
}
|
|
|
|
async function setPrincipalGrants(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
grants: GrantInput[],
|
|
grantedByUserId: string | null,
|
|
) {
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
),
|
|
);
|
|
if (grants.length === 0) return;
|
|
await tx.insert(principalPermissionGrants).values(
|
|
grants.map((grant) => ({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
permissionKey: grant.permissionKey,
|
|
scope: grant.scope ?? null,
|
|
grantedByUserId,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) {
|
|
const sourceMemberships = await listActiveUserMemberships(sourceCompanyId);
|
|
for (const membership of sourceMemberships) {
|
|
await ensureMembership(
|
|
targetCompanyId,
|
|
"user",
|
|
membership.principalId,
|
|
membership.membershipRole,
|
|
"active",
|
|
);
|
|
await ensureHumanRoleDefaultGrants(db, {
|
|
companyId: targetCompanyId,
|
|
principalId: membership.principalId,
|
|
membershipRole: membership.membershipRole,
|
|
grantedByUserId: null,
|
|
});
|
|
}
|
|
return sourceMemberships;
|
|
}
|
|
|
|
async function ensureRoleDefaultGrants(
|
|
companyId: string,
|
|
principalId: string,
|
|
membershipRole: string | null | undefined,
|
|
grantedByUserId: string | null,
|
|
) {
|
|
return ensureHumanRoleDefaultGrants(db, {
|
|
companyId,
|
|
principalId,
|
|
membershipRole,
|
|
grantedByUserId,
|
|
});
|
|
}
|
|
|
|
async function listPrincipalGrants(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
) {
|
|
return db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
),
|
|
)
|
|
.orderBy(principalPermissionGrants.permissionKey);
|
|
}
|
|
|
|
async function setPrincipalPermission(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
permissionKey: PermissionKey,
|
|
enabled: boolean,
|
|
grantedByUserId: string | null,
|
|
scope: Record<string, unknown> | null = null,
|
|
) {
|
|
if (!enabled) {
|
|
await db
|
|
.delete(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
eq(principalPermissionGrants.permissionKey, permissionKey),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await ensureMembership(companyId, principalType, principalId, "member", "active");
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
eq(principalPermissionGrants.permissionKey, permissionKey),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (existing) {
|
|
await db
|
|
.update(principalPermissionGrants)
|
|
.set({
|
|
scope,
|
|
grantedByUserId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(principalPermissionGrants.id, existing.id));
|
|
return;
|
|
}
|
|
|
|
await db.insert(principalPermissionGrants).values({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
permissionKey,
|
|
scope,
|
|
grantedByUserId,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
}
|
|
|
|
async function updateMember(
|
|
companyId: string,
|
|
memberId: string,
|
|
data: {
|
|
membershipRole?: string | null;
|
|
status?: "pending" | "active" | "suspended";
|
|
},
|
|
) {
|
|
return memberAccessTransaction(async (tx) => {
|
|
await tx.execute(sql`
|
|
select ${companyMemberships.id}
|
|
from ${companyMemberships}
|
|
where ${companyMemberships.companyId} = ${companyId}
|
|
and ${companyMemberships.principalType} = 'user'
|
|
and ${companyMemberships.status} = 'active'
|
|
and ${companyMemberships.membershipRole} = 'owner'
|
|
for update
|
|
`);
|
|
|
|
const existing = await tx
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const nextMembershipRole =
|
|
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
|
|
const nextStatus = data.status ?? existing.status;
|
|
|
|
if (existing.principalType === "user" && nextStatus === "active" && nextMembershipRole !== "viewer"
|
|
&& (existing.status !== "active" || existing.membershipRole === "viewer")) {
|
|
await notifyChatRunMilestones(tx, { companyId });
|
|
}
|
|
|
|
if (
|
|
existing.principalType === "user" &&
|
|
existing.status === "active" &&
|
|
existing.membershipRole === "owner" &&
|
|
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
|
) {
|
|
const activeOwnerCount = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
eq(companyMemberships.membershipRole, "owner"),
|
|
),
|
|
)
|
|
.then((rows) => rows.length);
|
|
if (activeOwnerCount <= 1) {
|
|
throw conflict("Cannot remove the last active owner");
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
if (
|
|
existing.principalType === "user" &&
|
|
existing.status !== "suspended" &&
|
|
nextStatus === "suspended"
|
|
) {
|
|
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
|
}
|
|
|
|
return tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
membershipRole: nextMembershipRole,
|
|
status: nextStatus,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(companyMemberships.id, existing.id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? existing);
|
|
});
|
|
}
|
|
|
|
return {
|
|
isInstanceAdmin,
|
|
decide,
|
|
decideReads: authorization.decideReads,
|
|
projectDiscoveryCandidateIds: authorization.projectDiscoveryCandidateIds,
|
|
canUser,
|
|
hasPermission,
|
|
getMembership,
|
|
getMemberById,
|
|
ensureMembership,
|
|
listMembers,
|
|
listActiveUserMemberships,
|
|
copyActiveUserMemberships,
|
|
ensureRoleDefaultGrants,
|
|
archiveMember,
|
|
setMemberPermissions,
|
|
updateMemberAndPermissions,
|
|
promoteInstanceAdmin,
|
|
demoteInstanceAdmin,
|
|
listUserCompanyAccess,
|
|
setUserCompanyAccess,
|
|
setPrincipalGrants,
|
|
listPrincipalGrants,
|
|
setPrincipalPermission,
|
|
updateMember,
|
|
};
|
|
}
|