mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat connections register and verify provider commands in the background. > - Discord command recovery ran inside the recurring Gateway runtime scan. > - Its existing durable records already store verification and retry deadlines. > - This pull request gives command registration its own shared-coordinator worker and scoped commit notifications. > - Idle command work stops querying between real deadlines. Existing command authority and recovery remain intact. ## Linked Issues or Issue Description Refs #17554 and #17561. Both foundations have merged. This slice targets master. Runtime discovery and the other chat maintenance duties remain separate work. **What happened?** The chat runtime maintenance loop called Discord command registration on every reconciliation. It repeatedly read durable command state even when the next remote verification or retry was minutes away. **Expected behavior** Use committed producer changes and saved deadlines. Keep the existing five-minute verification policy, Retry-After, uncertain-write quarantine, namespace ownership, and credential fences. Do not add a database table for the timer. **Steps to reproduce** Configure a Discord connection and let command registration settle. In the parent revision, repeated runtime reconciliation still invokes command recovery. This revision reconstructs command deadlines at startup and removes that call from runtime reconciliation. ## What Changed - Expose normalized deadlines from the existing registration store. Park indefinite retries and invalid or foreign-owned records. Signal before store-owned claims, receipts, and retry-result writes. - Discover eligible endpoints in bounded pages. Refresh only affected endpoint, connection, or company scopes after commits. Dispatch at most 25 due commands per pass with fair company rotation. - Wire configuration, credentials, provider lifecycle/failure paths, inbound activation, generic connection mutation/removal/health, access revocation, and company deletion. - Register the worker through the chat service and shared coordinator. Preserve direct foreground setup calls. Refresh an existing local Gateway through its current owner after command work. - Preserve dirty work during drain and concurrent changes, back off unresolved attempts, and join admitted provider work during shutdown. - Document producer coverage, retained deadlines, supported writers, verification failures, and remaining runtime/Cloud obligations in `doc/idle-discord-command-registration.md` and its dated audit. ## Verification Published head: `547fdf2139144e1e6484198278bf4bb0eade8794`. It normally integrates master at `f8563d25077b17681ab6ef5949f7b07415a58ede`, including landed health #17554 and company deletion #17561. Squash ancestry caused merge conflicts; resolution applied this slice's exact 26-file owned patch from its recorded parent to pinned master. Every other path matches master. Migration 0394 and the current lockfile are preserved. - **141 distinct targeted cases pass** on reconciled production source: 78 complete command-store, batching, coordinator and startup-timing cases; 8 real automatic Discord and held-transaction Slack cases; and 55 complete startup/export and company-deletion cases. The 1,581 other chat integration cases were deselected. - Server typecheck and build pass. The company-deletion fixture self-migrates an initially empty isolated database. The two Discord fixtures use separate migrated clones; this is not a fresh-schema claim for those fixtures. Signal-listener warnings remain recorded without assertion failures. - The parent integration preserves health and command notifications at shared producer sites. It changes no provider behavior, schema or coordinator contract. Prior producer, backlog, drain/resume, malformed-hint, transaction, namespace, shutdown and retry regressions remain in the complete command suite. - [Landing compatibility acceptance](https://github.com/paperclipai/paperclip/blob/547fdf2139144e1e6484198278bf4bb0eade8794/doc/idle-discord-command-registration.md#acceptance-after-the-company-deletion-parent-landed-october-27) records current scope and the earlier failures. Older full-matrix or combined-stack runs apply only to their stated revisions. - Fresh exact-head Greptile, Ponytail if present and all CI are required. No full local repository suite, live provider, Cloud alarm, host suspension or savings claim is made. ## Risks - Discord still has real five-minute verification and failure-retry obligations. This does not prove host sleep or external alarm coverage. - Runtime discovery, receipt-to-runtime projection, Gateway lease renewal and persistent sockets remain separate. The remaining runtime poll can still read command receipts; it no longer invokes the command protocol. - Process-local signals cover integrated writers. Independent SQL writers and other replicas require an explicit wake contract. Existing authorization and durable ownership checks remain authoritative. - Refreshes coalesce with a 30-second minimum spacing. Saved work is never discarded because a call returned without progress. - No schema migration, new table, workflow or lockfile change is introduced by this slice. Landed parent repairs are retained. The receiver child must receive this integration before landing. ## Model Used OpenAI Codex, GPT-6 family, with repository tools, code execution, and reasoning. The exact serving version and context-window size are not exposed in this session. ## 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) - [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 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 - [ ] All Paperclip CI gates are green - [ ] 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>
1195 lines
43 KiB
TypeScript
1195 lines
43 KiB
TypeScript
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);
|
|
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" &&
|
|
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") {
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({
|
|
status: "active",
|
|
membershipRole: existingMembership.membershipRole ?? "operator",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(companyMemberships.id, existingMembership.id));
|
|
}
|
|
continue;
|
|
}
|
|
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",
|
|
) {
|
|
const existing = await getMembership(companyId, principalType, principalId);
|
|
if (existing) {
|
|
if (existing.status !== status || existing.membershipRole !== membershipRole) {
|
|
const updated = await db
|
|
.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;
|
|
}
|
|
|
|
return db
|
|
.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" &&
|
|
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,
|
|
};
|
|
}
|