Files
PaperClipAI/server/src/services/access.ts
T
DottaandPaperclip a65ca09508 fix(runner): settle accepted results after shutdown failures (#15217)
## Thinking Path

> - Paperclip manages AI agents and the tasks they perform.
> - The native runner saves tool results and completion reports before
it releases a session.
> - Large project discovery responses can exceed the durable command
limit.
> - A shutdown failure can leave a saved answer waiting for workspace
repair.
> - Recovery reused the old assessment for a different status decision,
which violated a database constraint.
> - This pull request bounds discovery responses and lets recovery
commit the saved result after workspace repair.
> - The benefit is a task that reaches its correct final status without
another provider turn.

## Linked Issues or Issue Description

**What happened?**

A native run can save its final answer, fail during shutdown, and leave
the task In Progress after workspace repair succeeds. Reconciliation
tries to reuse the failed-workspace assessment for a new decision. The
one-decision-per-assessment constraint rejects the write. Replaying the
old decision can also retain a fresh coordinator lease. Separately,
retained session cleanup only recognizes the old `adapter_failed` label.

The project-list tool returns full project records, including large
descriptions and workspace configuration. A large response exceeds the
runner's durable command limit. The settlement diagnostic previously
recorded only a failure flag.

**Expected behavior**

Project discovery stays within the command limit. Recovery finishes the
saved result after workspace repair, releases its lease, and preserves
the original error for inspection. It does not repeat provider work or
relax session ownership checks.

**Steps to reproduce**

1. Return large project records from `list_projects` and observe an
oversized semantic result.
2. Persist an accepted native completion result, then record a shutdown
failure.
3. Finalize with a failed workspace, repeat that attempt, then record
successful workspace repair.
4. Reconcile the run. Before this fix, the issue stays In Progress.

**Paperclip version or commit**

Reproduced on `a386a599983519eb1d399f8b770bfccdb2a74762`.

**Deployment mode**

Self-hosted server with Paperclip Runner.

Related transport work: #12208 drains queued events; #12241 resumes
interrupted semantic calls. This change addresses bounded project
discovery and accepted-result finalization.

## What Changed

- Read bounded project summary projections from the database and return
at most 50 authorized summaries with a continuation cursor and explicit
description truncation. Agent and run trust boundaries narrow the
database candidates; project-specific policies still receive full
authorization. Only visible projects determine continuations. The
default project-list API remains unchanged.
- Record bounded, content-free settlement failure causes for command
limits, storage errors, and rejected dispatch.
- Include workspace state in assessment identity. Preserve the initial
assessment for interrupted finalization, and commit replacement
assessment and decision references together.
- Release the coordinator lease when an existing decision is replayed,
without repeating its effects.
- Clear stale errors when recovery succeeds and retain them in
`recoveredExecutionFailure`.
- Accept both current and legacy close-failure labels in the existing
exact-state cleanup path.
- Add regression tests and update the tool contracts and recovery
documentation.

## Verification

- Red: the new project paging, settlement diagnostic, current cleanup
label, and repaired-workspace regressions failed on the original
implementation.
- Green: protocol/catalog/tool checks (117 tests), the full project-tool
and finalizer suites (47 tests), cleanup ownership cases (70 tests), and
cleanup sweep cases (4 tests) pass. Database-backed pagination covers
large descriptions/configuration, complete enumeration, uppercase
cursors, agent/run restrictions, project-policy scope contributions, and
identical results/cursors when hidden projects are added.
- The initial CI failures in interrupted Board waits, contended endpoint
proof, and semantic schema validation were reproduced and fixed; all
affected cases pass locally.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — started before the review corrections; it spanned
several source revisions and was stopped after reporting old-behavior
and timing failures. It is not claimed green. Fresh project and recovery
suites pass; the recovery suite also passes all 25 cases with the broad
runner’s isolated home/config. The Slack timing case passed in
isolation. Latest-head CI is the authoritative complete test matrix.
- Existing authorization suite — 66 tests passed.
- Latest-head CI on `8e5763d915aa6f375bdab6601996899ea01496fc` — 55
checks passed, 4 intentionally skipped, no pending or failing checks.
- Greptile — 5/5, zero unresolved threads on the same commit.
- `git diff --check` — passed.

## Risks

- `list_projects` now returns summaries. Callers must follow
`nextCursor` and use the authorized project API for full records.
Candidate narrowing is only an optimization: project policy and
responsible-user authorization remain authoritative.
- Assessment identity changes for workspace finalization. Existing
evidence remains intact; no migration is needed.
- Cleanup still requires matching identities, settled tool evidence, and
verified process ownership. Unknown tool outcomes remain blocked from
session reuse.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, code execution, and GitHub
tools. The exact serving model ID and context window 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
- [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>
2026-10-05 10:31:11 -05:00

1154 lines
40 KiB
TypeScript

import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
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 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;
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,
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 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)),
);
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,
) {
const member = await getMemberById(companyId, memberId);
if (!member) return null;
await db.transaction(async (tx) => {
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 db.transaction(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 db.transaction(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 db.transaction(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)))
.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 db.transaction(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,
projectDiscoveryCandidateIds: authorization.projectDiscoveryCandidateIds,
canUser,
hasPermission,
getMembership,
getMemberById,
ensureMembership,
listMembers,
listActiveUserMemberships,
copyActiveUserMemberships,
ensureRoleDefaultGrants,
archiveMember,
setMemberPermissions,
updateMemberAndPermissions,
promoteInstanceAdmin,
demoteInstanceAdmin,
listUserCompanyAccess,
setUserCompanyAccess,
setPrincipalGrants,
listPrincipalGrants,
setPrincipalPermission,
updateMember,
};
}