From a6d7d235b8a93159b0cba956fb16a8bcce748d75 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:41:44 -0400 Subject: [PATCH 01/14] feat: add project and integration authorization foundation --- .../migration.sql | 275 +++++++++++ prisma/schema.prisma | 428 ++++++++++++------ src/server/services/authorization.ts | 125 ++++- tests/unit/authorization.test.ts | 56 +++ 4 files changed, 749 insertions(+), 135 deletions(-) create mode 100644 prisma/migrations/20260825190000_project_integration_authorization/migration.sql diff --git a/prisma/migrations/20260825190000_project_integration_authorization/migration.sql b/prisma/migrations/20260825190000_project_integration_authorization/migration.sql new file mode 100644 index 00000000..79ec5404 --- /dev/null +++ b/prisma/migrations/20260825190000_project_integration_authorization/migration.sql @@ -0,0 +1,275 @@ +-- Project visibility and explicit project/integration authorization. +-- Existing projects remain workspace-visible. Existing guest and integration +-- access is materialized explicitly so the authorization layer can become +-- deny-by-default without silently breaking established installations. + +CREATE TYPE "ProjectVisibility" AS ENUM ('WORKSPACE', 'RESTRICTED'); +CREATE TYPE "ProjectAccessRole" AS ENUM ('VIEWER', 'CONTRIBUTOR', 'MANAGER'); +CREATE TYPE "IntegrationCapability" AS ENUM ('READ', 'IMPORT', 'LINK', 'SYNC', 'WRITE', 'ADMIN'); +CREATE TYPE "IntegrationCredentialSource" AS ENUM ('USER_CONNECTION', 'WORKSPACE_GITHUB_APP'); +CREATE TYPE "IntegrationPrincipalType" AS ENUM ('USER', 'AGENT', 'API_KEY', 'WORKSPACE_AUTOMATION'); +CREATE TYPE "IntegrationGrantScope" AS ENUM ('WORKSPACE', 'PROJECT'); + +ALTER TYPE "EventKind" ADD VALUE 'PROJECT_ACCESS_CHANGED'; +ALTER TYPE "EventKind" ADD VALUE 'INTEGRATION_AUTHORIZATION_CHANGED'; + +ALTER TABLE "Project" + ADD COLUMN "visibility" "ProjectVisibility" NOT NULL DEFAULT 'WORKSPACE'; + +CREATE UNIQUE INDEX "Project_id_workspaceId_key" ON "Project"("id", "workspaceId"); +CREATE UNIQUE INDEX "Membership_id_workspaceId_key" ON "Membership"("id", "workspaceId"); +CREATE UNIQUE INDEX "ConnectionMapping_id_workspaceId_key" ON "ConnectionMapping"("id", "workspaceId"); +CREATE UNIQUE INDEX "GithubApp_id_workspaceId_key" ON "GithubApp"("id", "workspaceId"); +CREATE UNIQUE INDEX "ApiKey_id_workspaceId_key" ON "ApiKey"("id", "workspaceId"); +CREATE UNIQUE INDEX "Agent_id_workspaceId_key" ON "Agent"("id", "workspaceId"); +CREATE INDEX "Project_workspaceId_visibility_archived_idx" ON "Project"("workspaceId", "visibility", "archived"); + +CREATE TABLE "ProjectAccess" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "role" "ProjectAccessRole" NOT NULL, + "grantedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectAccess_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProjectAccess_projectId_membershipId_key" ON "ProjectAccess"("projectId", "membershipId"); +CREATE INDEX "ProjectAccess_workspaceId_membershipId_role_idx" ON "ProjectAccess"("workspaceId", "membershipId", "role"); +CREATE INDEX "ProjectAccess_workspaceId_projectId_idx" ON "ProjectAccess"("workspaceId", "projectId"); +CREATE INDEX "ProjectAccess_grantedById_idx" ON "ProjectAccess"("grantedById"); + +ALTER TABLE "ProjectAccess" ADD CONSTRAINT "ProjectAccess_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectAccess" ADD CONSTRAINT "ProjectAccess_projectId_workspaceId_fkey" + FOREIGN KEY ("projectId", "workspaceId") REFERENCES "Project"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectAccess" ADD CONSTRAINT "ProjectAccess_membershipId_workspaceId_fkey" + FOREIGN KEY ("membershipId", "workspaceId") REFERENCES "Membership"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectAccess" ADD CONSTRAINT "ProjectAccess_grantedById_fkey" + FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Preserve existing guest reads by turning them into explicit viewer grants. +INSERT INTO "ProjectAccess" ( + "id", "workspaceId", "projectId", "membershipId", "role", "createdAt", "updatedAt" +) +SELECT + 'pa_' || md5(p."id" || ':' || m."id"), + p."workspaceId", + p."id", + m."id", + 'VIEWER'::"ProjectAccessRole", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "Project" p +JOIN "Membership" m ON m."workspaceId" = p."workspaceId" AND m."role" = 'GUEST' +WHERE p."deletedAt" IS NULL +ON CONFLICT ("projectId", "membershipId") DO NOTHING; + +CREATE TABLE "ConnectionAuthorization" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "connectionMappingId" TEXT NOT NULL, + "credentialSource" "IntegrationCredentialSource" NOT NULL, + "githubAppId" TEXT, + "capabilities" "IntegrationCapability"[] NOT NULL, + "authorizedById" TEXT NOT NULL, + "authorizationDigest" TEXT NOT NULL, + "authorizedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedById" TEXT, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ConnectionAuthorization_pkey" PRIMARY KEY ("id"), + CONSTRAINT "ConnectionAuthorization_source_binding_check" CHECK ( + ("credentialSource" = 'USER_CONNECTION' AND "githubAppId" IS NULL) OR + ("credentialSource" = 'WORKSPACE_GITHUB_APP' AND "githubAppId" IS NOT NULL) + ), + CONSTRAINT "ConnectionAuthorization_capabilities_check" CHECK (cardinality("capabilities") > 0), + CONSTRAINT "ConnectionAuthorization_digest_check" CHECK (length("authorizationDigest") >= 16) +); + +CREATE UNIQUE INDEX "ConnectionAuthorization_connectionMappingId_key" ON "ConnectionAuthorization"("connectionMappingId"); +CREATE UNIQUE INDEX "ConnectionAuthorization_id_workspaceId_key" ON "ConnectionAuthorization"("id", "workspaceId"); +CREATE UNIQUE INDEX "ConnectionAuthorization_connectionMappingId_workspaceId_key" ON "ConnectionAuthorization"("connectionMappingId", "workspaceId"); +CREATE INDEX "ConnectionAuthorization_workspaceId_revokedAt_idx" ON "ConnectionAuthorization"("workspaceId", "revokedAt"); +CREATE INDEX "ConnectionAuthorization_githubAppId_idx" ON "ConnectionAuthorization"("githubAppId"); +CREATE INDEX "ConnectionAuthorization_authorizedById_idx" ON "ConnectionAuthorization"("authorizedById"); +CREATE INDEX "ConnectionAuthorization_revokedById_idx" ON "ConnectionAuthorization"("revokedById"); + +ALTER TABLE "ConnectionAuthorization" ADD CONSTRAINT "ConnectionAuthorization_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ConnectionAuthorization" ADD CONSTRAINT "ConnectionAuthorization_connectionMappingId_workspaceId_fkey" + FOREIGN KEY ("connectionMappingId", "workspaceId") REFERENCES "ConnectionMapping"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ConnectionAuthorization" ADD CONSTRAINT "ConnectionAuthorization_githubAppId_workspaceId_fkey" + FOREIGN KEY ("githubAppId", "workspaceId") REFERENCES "GithubApp"("id", "workspaceId") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ConnectionAuthorization" ADD CONSTRAINT "ConnectionAuthorization_authorizedById_fkey" + FOREIGN KEY ("authorizedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ConnectionAuthorization" ADD CONSTRAINT "ConnectionAuthorization_revokedById_fkey" + FOREIGN KEY ("revokedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- A GithubApp-backed Connection stores its installation id in config. Bind +-- that exact app when possible; all other mappings remain user-owned. +INSERT INTO "ConnectionAuthorization" ( + "id", "workspaceId", "connectionMappingId", "credentialSource", + "githubAppId", "capabilities", "authorizedById", "authorizationDigest", + "authorizedAt", "createdAt", "updatedAt" +) +SELECT + 'ca_' || md5(cm."id"), + cm."workspaceId", + cm."id", + CASE WHEN ga."id" IS NULL + THEN 'USER_CONNECTION'::"IntegrationCredentialSource" + ELSE 'WORKSPACE_GITHUB_APP'::"IntegrationCredentialSource" + END, + ga."id", + ARRAY['READ','IMPORT','LINK','SYNC','WRITE','ADMIN']::"IntegrationCapability"[], + c."ownerId", + 'legacy:' || cm."id", + cm."updatedAt", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "ConnectionMapping" cm +JOIN "Connection" c ON c."id" = cm."connectionId" +LEFT JOIN LATERAL ( + SELECT app."id" + FROM "GithubApp" app + WHERE app."workspaceId" = cm."workspaceId" + AND app."installationId" IS NOT NULL + AND app."installationId" = c."config"->>'installationId' + ORDER BY app."createdAt" ASC + LIMIT 1 +) ga ON TRUE; + +CREATE TABLE "IntegrationGrant" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "connectionAuthorizationId" TEXT NOT NULL, + "principalType" "IntegrationPrincipalType" NOT NULL, + "principalUserId" TEXT, + "principalAgentId" TEXT, + "principalApiKeyId" TEXT, + "scope" "IntegrationGrantScope" NOT NULL, + "projectId" TEXT, + "capabilities" "IntegrationCapability"[] NOT NULL, + "grantedById" TEXT, + "revokedById" TEXT, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "IntegrationGrant_pkey" PRIMARY KEY ("id"), + CONSTRAINT "IntegrationGrant_principal_check" CHECK ( + ("principalType" = 'USER' AND "principalUserId" IS NOT NULL AND "principalAgentId" IS NULL AND "principalApiKeyId" IS NULL) OR + ("principalType" = 'AGENT' AND "principalUserId" IS NULL AND "principalAgentId" IS NOT NULL AND "principalApiKeyId" IS NULL) OR + ("principalType" = 'API_KEY' AND "principalUserId" IS NULL AND "principalAgentId" IS NULL AND "principalApiKeyId" IS NOT NULL) OR + ("principalType" = 'WORKSPACE_AUTOMATION' AND "principalUserId" IS NULL AND "principalAgentId" IS NULL AND "principalApiKeyId" IS NULL) + ), + CONSTRAINT "IntegrationGrant_scope_check" CHECK ( + ("scope" = 'WORKSPACE' AND "projectId" IS NULL) OR + ("scope" = 'PROJECT' AND "projectId" IS NOT NULL) + ), + CONSTRAINT "IntegrationGrant_capabilities_check" CHECK (cardinality("capabilities") > 0) +); + +CREATE INDEX "IntegrationGrant_workspaceId_principalType_revokedAt_idx" ON "IntegrationGrant"("workspaceId", "principalType", "revokedAt"); +CREATE INDEX "IntegrationGrant_connectionAuthorizationId_revokedAt_idx" ON "IntegrationGrant"("connectionAuthorizationId", "revokedAt"); +CREATE INDEX "IntegrationGrant_workspaceId_projectId_idx" ON "IntegrationGrant"("workspaceId", "projectId"); +CREATE INDEX "IntegrationGrant_principalUserId_idx" ON "IntegrationGrant"("principalUserId"); +CREATE INDEX "IntegrationGrant_principalAgentId_idx" ON "IntegrationGrant"("principalAgentId"); +CREATE INDEX "IntegrationGrant_principalApiKeyId_idx" ON "IntegrationGrant"("principalApiKeyId"); +CREATE INDEX "IntegrationGrant_grantedById_idx" ON "IntegrationGrant"("grantedById"); +CREATE INDEX "IntegrationGrant_revokedById_idx" ON "IntegrationGrant"("revokedById"); +CREATE UNIQUE INDEX "IntegrationGrant_active_principal_scope_key" + ON "IntegrationGrant" ( + "workspaceId", "connectionAuthorizationId", "principalType", + COALESCE("principalUserId", ''), COALESCE("principalAgentId", ''), + COALESCE("principalApiKeyId", ''), "scope", COALESCE("projectId", '') + ) WHERE "revokedAt" IS NULL; + +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_workspaceId_fkey" + FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_connectionAuthorizationId_workspaceId_fkey" + FOREIGN KEY ("connectionAuthorizationId", "workspaceId") REFERENCES "ConnectionAuthorization"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalUserId_fkey" + FOREIGN KEY ("principalUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalUserId_workspaceId_fkey" + FOREIGN KEY ("principalUserId", "workspaceId") REFERENCES "Membership"("userId", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalAgentId_workspaceId_fkey" + FOREIGN KEY ("principalAgentId", "workspaceId") REFERENCES "Agent"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalApiKeyId_workspaceId_fkey" + FOREIGN KEY ("principalApiKeyId", "workspaceId") REFERENCES "ApiKey"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_projectId_workspaceId_fkey" + FOREIGN KEY ("projectId", "workspaceId") REFERENCES "Project"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_grantedById_fkey" + FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_revokedById_fkey" + FOREIGN KEY ("revokedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Preserve the previously workspace-wide effective access. Existing API-key +-- scope checks remain an independent ceiling; these grants do not widen them. +INSERT INTO "IntegrationGrant" ( + "id", "workspaceId", "connectionAuthorizationId", "principalType", + "principalUserId", "scope", "capabilities", "grantedById", + "createdAt", "updatedAt" +) +SELECT + 'ig_u_' || md5(ca."id" || ':' || m."userId"), + ca."workspaceId", + ca."id", + 'USER'::"IntegrationPrincipalType", + m."userId", + 'WORKSPACE'::"IntegrationGrantScope", + CASE WHEN m."role" IN ('OWNER', 'ADMIN') + THEN ARRAY['READ','IMPORT','LINK','SYNC','WRITE','ADMIN']::"IntegrationCapability"[] + ELSE ARRAY['READ','IMPORT','LINK','SYNC']::"IntegrationCapability"[] + END, + ca."authorizedById", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "ConnectionAuthorization" ca +JOIN "Membership" m ON m."workspaceId" = ca."workspaceId"; + +INSERT INTO "IntegrationGrant" ( + "id", "workspaceId", "connectionAuthorizationId", "principalType", + "principalAgentId", "scope", "capabilities", "grantedById", + "createdAt", "updatedAt" +) +SELECT + 'ig_a_' || md5(ca."id" || ':' || a."id"), + ca."workspaceId", ca."id", 'AGENT'::"IntegrationPrincipalType", a."id", + 'WORKSPACE'::"IntegrationGrantScope", + ARRAY['READ','IMPORT','LINK','SYNC','WRITE','ADMIN']::"IntegrationCapability"[], + ca."authorizedById", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "ConnectionAuthorization" ca +JOIN "Agent" a ON a."workspaceId" = ca."workspaceId"; + +INSERT INTO "IntegrationGrant" ( + "id", "workspaceId", "connectionAuthorizationId", "principalType", + "principalApiKeyId", "scope", "capabilities", "grantedById", + "createdAt", "updatedAt" +) +SELECT + 'ig_k_' || md5(ca."id" || ':' || k."id"), + ca."workspaceId", ca."id", 'API_KEY'::"IntegrationPrincipalType", k."id", + 'WORKSPACE'::"IntegrationGrantScope", + ARRAY['READ','IMPORT','LINK','SYNC','WRITE','ADMIN']::"IntegrationCapability"[], + ca."authorizedById", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "ConnectionAuthorization" ca +JOIN "ApiKey" k ON k."workspaceId" = ca."workspaceId" AND k."revokedAt" IS NULL; + +INSERT INTO "IntegrationGrant" ( + "id", "workspaceId", "connectionAuthorizationId", "principalType", + "scope", "capabilities", "grantedById", "createdAt", "updatedAt" +) +SELECT + 'ig_w_' || md5(ca."id"), + ca."workspaceId", ca."id", 'WORKSPACE_AUTOMATION'::"IntegrationPrincipalType", + 'WORKSPACE'::"IntegrationGrantScope", + ARRAY['READ','IMPORT','LINK','SYNC','WRITE','ADMIN']::"IntegrationCapability"[], + ca."authorizedById", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "ConnectionAuthorization" ca +JOIN "ConnectionMapping" cm ON cm."id" = ca."connectionMappingId" +WHERE cm."status" = 'active' + AND cm."direction" IN ('inbound', 'inbound+outbound'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d64d2a33..693dc08f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,48 @@ enum Role { GUEST } +/// Default audience for a project. RESTRICTED projects require an explicit +/// ProjectAccess row for non-administrators. +enum ProjectVisibility { + WORKSPACE + RESTRICTED +} + +/// Increasing project authority granted to one workspace membership. +enum ProjectAccessRole { + VIEWER + CONTRIBUTOR + MANAGER +} + +/// Explicit operations a person may perform through an external credential. +enum IntegrationCapability { + READ + IMPORT + LINK + SYNC + WRITE + ADMIN +} + +/// Stable provenance for the credential behind a connection mapping. +enum IntegrationCredentialSource { + USER_CONNECTION + WORKSPACE_GITHUB_APP +} + +enum IntegrationPrincipalType { + USER + AGENT + API_KEY + WORKSPACE_AUTOMATION +} + +enum IntegrationGrantScope { + WORKSPACE + PROJECT +} + /// Presentation and default-provisioning profile for a workspace. This does /// not change tenancy, permissions, or agent capability: PERSONAL keeps the /// same underlying work model while simplifying the default experience. @@ -183,6 +225,8 @@ enum EventKind { COMMENT_UPDATED PROJECT_CREATED PROJECT_UPDATED + PROJECT_ACCESS_CHANGED + INTEGRATION_AUTHORIZATION_CHANGED SKILL_INVOKED PLUGIN_ERROR AGENT_CREATED @@ -905,58 +949,64 @@ model User { externalResourceLinks ExternalResourceLink[] @relation("ExternalResourceLinkCreator") ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerUser") - authoredIssues Issue[] @relation("IssueAuthor") + authoredIssues Issue[] @relation("IssueAuthor") assignedIssues IssueAssignee[] comments Comment[] auditEntries AuditLog[] events ActivityEvent[] notificationStates NotificationState[] pushSubscriptions PushSubscription[] - createdProjects Project[] @relation("ProjectCreator") + createdProjects Project[] @relation("ProjectCreator") + projectAccessesGranted ProjectAccess[] @relation("ProjectAccessGrantor") + connectionAuthorizationsGranted ConnectionAuthorization[] @relation("ConnectionAuthorizationGrantor") + connectionAuthorizationsRevoked ConnectionAuthorization[] @relation("ConnectionAuthorizationRevoker") + integrationGrantsAsPrincipal IntegrationGrant[] @relation("IntegrationGrantUserPrincipal") + integrationGrantsGranted IntegrationGrant[] @relation("IntegrationGrantGrantor") + integrationGrantsRevoked IntegrationGrant[] @relation("IntegrationGrantRevoker") apiKeys ApiKey[] - claimedIssues Issue[] @relation("IssueClaimedBy") - defaultIssueAssigneeForWorkspaces Workspace[] @relation("WorkspaceDefaultIssueAssignee") - recurringCreated RecurringIssue[] @relation("RecurringCreator") - scheduledTasksCreated ScheduledTask[] @relation("ScheduledTaskCreator") + claimedIssues Issue[] @relation("IssueClaimedBy") + defaultIssueAssigneeForWorkspaces Workspace[] @relation("WorkspaceDefaultIssueAssignee") + recurringCreated RecurringIssue[] @relation("RecurringCreator") + scheduledTasksCreated ScheduledTask[] @relation("ScheduledTaskCreator") savedViews SavedView[] issueSavedViews IssueSavedView[] timeEntries TimeEntry[] chatThreads ChatThread[] chatThreadReads ChatThreadRead[] runtimes Runtime[] - controlRequestedRuns AgentRun[] @relation("AgentRunControlRequester") - clearedAgentRuns AgentRun[] @relation("AgentRunClearer") + controlRequestedRuns AgentRun[] @relation("AgentRunControlRequester") + clearedAgentRuns AgentRun[] @relation("AgentRunClearer") pins Pin[] recentItems RecentItem[] notes Note[] issueWatches IssueWatcher[] - createdArtifacts Artifact[] @relation("ArtifactCreator") - acceptedArtifacts Artifact[] @relation("ArtifactAcceptor") - artifactVersions ArtifactVersion[] @relation("ArtifactVersionCreator") + createdArtifacts Artifact[] @relation("ArtifactCreator") + acceptedArtifacts Artifact[] @relation("ArtifactAcceptor") + artifactVersions ArtifactVersion[] @relation("ArtifactVersionCreator") artifactGrants ArtifactGrant[] - artifactComments ArtifactComment[] @relation("ArtifactCommentAuthor") - resolvedArtifactComments ArtifactComment[] @relation("ArtifactCommentResolver") - artifactPublications ArtifactPublication[] @relation("ArtifactPublicationCreator") - revokedArtifactPublications ArtifactPublication[] @relation("ArtifactPublicationRevoker") - artifactDeployments ArtifactDeployment[] @relation("ArtifactDeploymentCreator") - ownedContextSets ContextSet[] @relation("ContextSetOwnerUser") - createdExecutionPlans ExecutionPlan[] @relation("ExecutionPlanCreator") - createdGoals Goal[] @relation("GoalCreator") - executionSteps ExecutionStep[] @relation("ExecutionStepUser") - requestedReviewGates ReviewGate[] @relation("ReviewGateRequester") - resolvedReviewGates ReviewGate[] @relation("ReviewGateResolver") - requestedActionRequests ActionRequest[] @relation("ActionRequestRequester") - assignedActionRequests ActionRequest[] @relation("ActionRequestAssignee") - resolvedActionRequests ActionRequest[] @relation("ActionRequestResolver") + artifactComments ArtifactComment[] @relation("ArtifactCommentAuthor") + resolvedArtifactComments ArtifactComment[] @relation("ArtifactCommentResolver") + artifactPublications ArtifactPublication[] @relation("ArtifactPublicationCreator") + revokedArtifactPublications ArtifactPublication[] @relation("ArtifactPublicationRevoker") + artifactDeployments ArtifactDeployment[] @relation("ArtifactDeploymentCreator") + ownedContextSets ContextSet[] @relation("ContextSetOwnerUser") + createdExecutionPlans ExecutionPlan[] @relation("ExecutionPlanCreator") + createdGoals Goal[] @relation("GoalCreator") + executionSteps ExecutionStep[] @relation("ExecutionStepUser") + requestedReviewGates ReviewGate[] @relation("ReviewGateRequester") + resolvedReviewGates ReviewGate[] @relation("ReviewGateResolver") + requestedActionRequests ActionRequest[] @relation("ActionRequestRequester") + assignedActionRequests ActionRequest[] @relation("ActionRequestAssignee") + resolvedActionRequests ActionRequest[] @relation("ActionRequestResolver") actionRequestVotes ActionRequestVote[] notificationPreferences NotificationPreference[] - createdCanvases WorkspaceCanvas[] @relation("WorkspaceCanvasCreator") - ownedCanvases WorkspaceCanvas[] @relation("WorkspaceCanvasOwner") - createdShapes CanvasShape[] @relation("CanvasShapeCreator") - createdCanvasFrames CanvasFrame[] @relation("CanvasFrameCreator") - createdCanvasGroups CanvasGroup[] @relation("CanvasGroupCreator") - createdCanvasComponents CanvasComponent[] @relation("CanvasComponentCreator") - createdCanvasStyles CanvasStyle[] @relation("CanvasStyleCreator") + createdCanvases WorkspaceCanvas[] @relation("WorkspaceCanvasCreator") + ownedCanvases WorkspaceCanvas[] @relation("WorkspaceCanvasOwner") + createdShapes CanvasShape[] @relation("CanvasShapeCreator") + createdCanvasFrames CanvasFrame[] @relation("CanvasFrameCreator") + createdCanvasGroups CanvasGroup[] @relation("CanvasGroupCreator") + createdCanvasComponents CanvasComponent[] @relation("CanvasComponentCreator") + createdCanvasStyles CanvasStyle[] @relation("CanvasStyleCreator") @@index([handle]) } @@ -1372,84 +1422,87 @@ model Workspace { defaultIssueAssigneeUser User? @relation("WorkspaceDefaultIssueAssignee", fields: [defaultIssueAssigneeUserId], references: [id], onDelete: SetNull) - memberships Membership[] - invitations WorkspaceInvitation[] - projects Project[] - issues Issue[] - statuses Status[] - labels Label[] - comments Comment[] - attachments Attachment[] - auditLogs AuditLog[] - events ActivityEvent[] - notificationStates NotificationState[] - plugins Plugin[] - apiKeys ApiKey[] - agentConnections AgentConnection[] - webhooks Webhook[] - metrics MetricAggregate[] - skills Skill[] - templates IssueTemplate[] - projectTemplates ProjectTemplate[] - recurring RecurringIssue[] - scheduledTasks ScheduledTask[] - scheduledTaskRuns ScheduledTaskRun[] - savedViews SavedView[] - issueSavedViews IssueSavedView[] - cycles Cycle[] - initiatives Initiative[] - issueRelations IssueRelation[] - timeEntries TimeEntry[] - agents Agent[] - dispatchRules DispatchRule[] - agentRuns AgentRun[] - agentRunEvents AgentRunEvent[] - chatThreads ChatThread[] - chatThreadReads ChatThreadRead[] - chatMessages ChatMessage[] - connectorSessions ConnectorSession[] - connectorDeliveries ConnectorDelivery[] - runtimes Runtime[] - githubApps GithubApp[] - providerCredentials ProviderCredential[] - connectionMappings ConnectionMapping[] - externalResources ExternalResource[] - externalResourceLinks ExternalResourceLink[] - externalWebhookEvents ExternalWebhookEvent[] - workSessions WorkSession[] - workSessionParticipants WorkSessionParticipant[] - pins Pin[] - recentItems RecentItem[] - notes Note[] - issueWatchers IssueWatcher[] - artifacts Artifact[] - artifactVersions ArtifactVersion[] - artifactGrants ArtifactGrant[] - artifactComments ArtifactComment[] - artifactPublications ArtifactPublication[] - artifactDeployments ArtifactDeployment[] - contextSets ContextSet[] - contextSetItems ContextSetItem[] - executionPlans ExecutionPlan[] - goals Goal[] - executionSteps ExecutionStep[] - agentCrews AgentCrew[] - agentCrewMembers AgentCrewMember[] - reviewGates ReviewGate[] - actionRequests ActionRequest[] - notificationPreferences NotificationPreference[] - canvases WorkspaceCanvas[] - canvasNodes WorkspaceCanvasNode[] - canvasEdges WorkspaceCanvasEdge[] - canvasShapes CanvasShape[] - canvasFrames CanvasFrame[] - canvasGroups CanvasGroup[] - canvasComponents CanvasComponent[] - canvasInstances CanvasComponentInstance[] - canvasStyles CanvasStyle[] - startedStatus Status? @relation("WorkspaceStartedStatus", fields: [startedStatusId], references: [id], onDelete: SetNull) - reviewStatus Status? @relation("WorkspaceReviewStatus", fields: [reviewStatusId], references: [id], onDelete: SetNull) - completionStatus Status? @relation("WorkspaceCompletionStatus", fields: [completionStatusId], references: [id], onDelete: SetNull) + memberships Membership[] + invitations WorkspaceInvitation[] + projects Project[] + issues Issue[] + statuses Status[] + labels Label[] + comments Comment[] + attachments Attachment[] + auditLogs AuditLog[] + events ActivityEvent[] + notificationStates NotificationState[] + plugins Plugin[] + apiKeys ApiKey[] + agentConnections AgentConnection[] + webhooks Webhook[] + metrics MetricAggregate[] + skills Skill[] + templates IssueTemplate[] + projectTemplates ProjectTemplate[] + recurring RecurringIssue[] + scheduledTasks ScheduledTask[] + scheduledTaskRuns ScheduledTaskRun[] + savedViews SavedView[] + issueSavedViews IssueSavedView[] + cycles Cycle[] + initiatives Initiative[] + issueRelations IssueRelation[] + timeEntries TimeEntry[] + agents Agent[] + dispatchRules DispatchRule[] + agentRuns AgentRun[] + agentRunEvents AgentRunEvent[] + chatThreads ChatThread[] + chatThreadReads ChatThreadRead[] + chatMessages ChatMessage[] + connectorSessions ConnectorSession[] + connectorDeliveries ConnectorDelivery[] + runtimes Runtime[] + githubApps GithubApp[] + providerCredentials ProviderCredential[] + connectionMappings ConnectionMapping[] + projectAccesses ProjectAccess[] + connectionAuthorizations ConnectionAuthorization[] + integrationGrants IntegrationGrant[] + externalResources ExternalResource[] + externalResourceLinks ExternalResourceLink[] + externalWebhookEvents ExternalWebhookEvent[] + workSessions WorkSession[] + workSessionParticipants WorkSessionParticipant[] + pins Pin[] + recentItems RecentItem[] + notes Note[] + issueWatchers IssueWatcher[] + artifacts Artifact[] + artifactVersions ArtifactVersion[] + artifactGrants ArtifactGrant[] + artifactComments ArtifactComment[] + artifactPublications ArtifactPublication[] + artifactDeployments ArtifactDeployment[] + contextSets ContextSet[] + contextSetItems ContextSetItem[] + executionPlans ExecutionPlan[] + goals Goal[] + executionSteps ExecutionStep[] + agentCrews AgentCrew[] + agentCrewMembers AgentCrewMember[] + reviewGates ReviewGate[] + actionRequests ActionRequest[] + notificationPreferences NotificationPreference[] + canvases WorkspaceCanvas[] + canvasNodes WorkspaceCanvasNode[] + canvasEdges WorkspaceCanvasEdge[] + canvasShapes CanvasShape[] + canvasFrames CanvasFrame[] + canvasGroups CanvasGroup[] + canvasComponents CanvasComponent[] + canvasInstances CanvasComponentInstance[] + canvasStyles CanvasStyle[] + startedStatus Status? @relation("WorkspaceStartedStatus", fields: [startedStatusId], references: [id], onDelete: SetNull) + reviewStatus Status? @relation("WorkspaceReviewStatus", fields: [reviewStatusId], references: [id], onDelete: SetNull) + completionStatus Status? @relation("WorkspaceCompletionStatus", fields: [completionStatusId], references: [id], onDelete: SetNull) @@index([slug]) @@index([defaultIssueAssigneeUserId]) @@ -1467,10 +1520,13 @@ model Membership { missionControlDefaultTab String? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + projectAccesses ProjectAccess[] @relation("ProjectAccessMembership") + integrationGrants IntegrationGrant[] @relation("IntegrationGrantUserMembership") @@unique([userId, workspaceId]) + @@unique([id, workspaceId]) @@index([workspaceId, role]) } @@ -1517,6 +1573,7 @@ model Project { icon String? color String? archived Boolean @default(false) + visibility ProjectVisibility @default(WORKSPACE) startDate DateTime? targetDate DateTime? /// Optional parent initiative that groups related projects. @@ -1541,22 +1598,50 @@ model Project { updatedAt DateTime @updatedAt deletedAt DateTime? - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - createdBy User @relation("ProjectCreator", fields: [createdById], references: [id]) - initiative Initiative? @relation(fields: [initiativeId], references: [id], onDelete: SetNull) - issues Issue[] - templates IssueTemplate[] - recurring RecurringIssue[] - scheduledTasks ScheduledTask[] - dispatchRules DispatchRule[] - artifacts Artifact[] - executionPlans ExecutionPlan[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("ProjectCreator", fields: [createdById], references: [id]) + initiative Initiative? @relation(fields: [initiativeId], references: [id], onDelete: SetNull) + issues Issue[] + templates IssueTemplate[] + recurring RecurringIssue[] + scheduledTasks ScheduledTask[] + dispatchRules DispatchRule[] + artifacts Artifact[] + executionPlans ExecutionPlan[] + accessGrants ProjectAccess[] + integrationGrants IntegrationGrant[] @@unique([workspaceId, key]) + @@unique([id, workspaceId]) @@index([workspaceId, archived]) + @@index([workspaceId, visibility, archived]) @@index([initiativeId]) } +/// Explicit project authority for one workspace membership. The composite +/// relations make cross-tenant grants impossible even if a caller supplies +/// otherwise-valid ids from different workspaces. +model ProjectAccess { + id String @id @default(cuid()) + workspaceId String + projectId String + membershipId String + role ProjectAccessRole + grantedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + membership Membership @relation("ProjectAccessMembership", fields: [membershipId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + grantedBy User? @relation("ProjectAccessGrantor", fields: [grantedById], references: [id], onDelete: SetNull) + + @@unique([projectId, membershipId]) + @@index([workspaceId, membershipId, role]) + @@index([workspaceId, projectId]) + @@index([grantedById]) +} + /// Workspace-scoped status; categories map to StatusCategory enum. model Status { id String @id @default(cuid()) @@ -2262,12 +2347,14 @@ model ApiKey { revokedAt DateTime? createdAt DateTime @default(now()) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - plugin Plugin? @relation(fields: [pluginId], references: [id], onDelete: Cascade) - linkedAgent Agent? @relation("AgentApiKeys", fields: [linkedAgentId], references: [id], onDelete: SetNull) - agentConnections AgentConnection[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + plugin Plugin? @relation(fields: [pluginId], references: [id], onDelete: Cascade) + linkedAgent Agent? @relation("AgentApiKeys", fields: [linkedAgentId], references: [id], onDelete: SetNull) + agentConnections AgentConnection[] + integrationGrants IntegrationGrant[] @relation("IntegrationGrantApiKeyPrincipal") + @@unique([id, workspaceId]) @@index([workspaceId]) @@index([prefix]) @@index([linkedAgentId]) @@ -2617,9 +2704,11 @@ model GithubApp { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - runtimes Runtime[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + runtimes Runtime[] + connectionAuthorizations ConnectionAuthorization[] + @@unique([id, workspaceId]) @@index([workspaceId]) @@index([installationId]) } @@ -2720,14 +2809,89 @@ model ConnectionMapping { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) externalResources ExternalResource[] + authorization ConnectionAuthorization? + @@unique([id, workspaceId]) @@index([workspaceId]) @@index([connectionId]) } +/// Credential-owner consent for one workspace mapping. This is deliberately +/// separate from principal grants: revoking consent invalidates every grant, +/// while changing a user's permissions does not mutate credential ownership. +model ConnectionAuthorization { + id String @id @default(cuid()) + workspaceId String + connectionMappingId String @unique + credentialSource IntegrationCredentialSource + githubAppId String? + capabilities IntegrationCapability[] + authorizedById String + authorizationDigest String + authorizedAt DateTime @default(now()) + revokedById String? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + connectionMapping ConnectionMapping @relation(fields: [connectionMappingId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + githubApp GithubApp? @relation(fields: [githubAppId, workspaceId], references: [id, workspaceId], onDelete: Restrict) + authorizedBy User @relation("ConnectionAuthorizationGrantor", fields: [authorizedById], references: [id], onDelete: Restrict) + revokedBy User? @relation("ConnectionAuthorizationRevoker", fields: [revokedById], references: [id], onDelete: SetNull) + grants IntegrationGrant[] + + @@unique([id, workspaceId]) + @@unique([connectionMappingId, workspaceId]) + @@index([workspaceId, revokedAt]) + @@index([githubAppId]) + @@index([authorizedById]) + @@index([revokedById]) +} + +/// Explicit authority for a user, agent, API key, or workspace automation to +/// exercise a ConnectionAuthorization. SQL CHECK constraints enforce that the +/// principal and project foreign keys match their discriminators. +model IntegrationGrant { + id String @id @default(cuid()) + workspaceId String + connectionAuthorizationId String + principalType IntegrationPrincipalType + principalUserId String? + principalAgentId String? + principalApiKeyId String? + scope IntegrationGrantScope + projectId String? + capabilities IntegrationCapability[] + grantedById String? + revokedById String? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + connectionAuthorization ConnectionAuthorization @relation(fields: [connectionAuthorizationId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + principalUser User? @relation("IntegrationGrantUserPrincipal", fields: [principalUserId], references: [id], onDelete: Cascade) + principalMembership Membership? @relation("IntegrationGrantUserMembership", fields: [principalUserId, workspaceId], references: [userId, workspaceId], onDelete: Cascade) + principalAgent Agent? @relation("IntegrationGrantAgentPrincipal", fields: [principalAgentId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + principalApiKey ApiKey? @relation("IntegrationGrantApiKeyPrincipal", fields: [principalApiKeyId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + project Project? @relation(fields: [projectId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + grantedBy User? @relation("IntegrationGrantGrantor", fields: [grantedById], references: [id], onDelete: SetNull) + revokedBy User? @relation("IntegrationGrantRevoker", fields: [revokedById], references: [id], onDelete: SetNull) + + @@index([workspaceId, principalType, revokedAt]) + @@index([connectionAuthorizationId, revokedAt]) + @@index([workspaceId, projectId]) + @@index([principalUserId]) + @@index([principalAgentId]) + @@index([principalApiKeyId]) + @@index([grantedById]) + @@index([revokedById]) +} + /// Workspace-scoped snapshot of an object in an external work system. /// GitHub is the first provider; the table stays provider-generic so /// future integrations can reuse the identity/link/sync layer without @@ -3151,8 +3315,10 @@ model Agent { actorAuditLogs AuditLog[] @relation("AuditLogActorAgent") actorActivityEvents ActivityEvent[] @relation("ActivityEventActorAgent") ownedWorkSessions WorkSession[] @relation("WorkSessionOwnerAgent") + integrationGrants IntegrationGrant[] @relation("IntegrationGrantAgentPrincipal") @@unique([workspaceId, profileKey]) + @@unique([id, workspaceId]) @@index([workspaceId, status]) @@index([runtimeId]) @@index([profileId]) diff --git a/src/server/services/authorization.ts b/src/server/services/authorization.ts index 622c0901..e256f7f0 100644 --- a/src/server/services/authorization.ts +++ b/src/server/services/authorization.ts @@ -1,4 +1,11 @@ -import type { Role } from "@prisma/client"; +import { + IntegrationCapability, + ProjectAccessRole, + ProjectVisibility, + type Prisma, + type PrismaClient, + type Role, +} from "@prisma/client"; import { TRPCError } from "@trpc/server"; /** @@ -44,8 +51,6 @@ export function assertWorkspaceAction(role: Role, action: WorkspaceAction): void * These string unions deliberately do not claim that persistence exists yet; * callers pass the resolved grant (or null) after loading it. */ -export type ProjectVisibility = "WORKSPACE" | "RESTRICTED"; -export type ProjectAccessRole = "VIEWER" | "CONTRIBUTOR" | "MANAGER"; export type ProjectAction = "READ" | "CONTRIBUTE" | "MANAGE"; const PROJECT_ROLE_RANK: Record = { @@ -82,12 +87,124 @@ export function canPerformProjectAction(params: { ); } +const PROJECT_ACCESS_ROLES: Record = { + READ: [ProjectAccessRole.VIEWER, ProjectAccessRole.CONTRIBUTOR, ProjectAccessRole.MANAGER], + CONTRIBUTE: [ProjectAccessRole.CONTRIBUTOR, ProjectAccessRole.MANAGER], + MANAGE: [ProjectAccessRole.MANAGER], +}; + +/** + * Tenant-scoped list predicate matching {@link canPerformProjectAction}. + * Callers must AND this fragment with any resource-specific filters. + */ +export function buildProjectAccessWhere(params: { + workspaceId: string; + membershipId: string; + membershipRole: Role; + action: ProjectAction; +}): Prisma.ProjectWhereInput { + const tenant = { workspaceId: params.workspaceId }; + if (isWorkspaceAdmin(params.membershipRole)) return tenant; + + const explicit: Prisma.ProjectWhereInput = { + accessGrants: { + some: { + membershipId: params.membershipId, + role: { in: PROJECT_ACCESS_ROLES[params.action] }, + }, + }, + }; + + if (params.membershipRole === "MEMBER" && params.action !== "MANAGE") { + return { + ...tenant, + OR: [{ visibility: ProjectVisibility.WORKSPACE }, explicit], + }; + } + + return { ...tenant, ...explicit }; +} + +type ProjectAuthorizationDb = Pick; + +export interface ProjectDecision { + project: { + id: string; + workspaceId: string; + visibility: ProjectVisibility; + }; + accessRole: ProjectAccessRole | null; + allowed: boolean; +} + +/** Resolve one project decision without leaking a cross-tenant row. */ +export async function resolveProjectDecision( + db: ProjectAuthorizationDb, + params: { + workspaceId: string; + membershipId: string; + membershipRole: Role; + projectId: string; + action: ProjectAction; + }, +): Promise { + const project = await db.project.findFirst({ + where: { id: params.projectId, workspaceId: params.workspaceId, deletedAt: null }, + select: { + id: true, + workspaceId: true, + visibility: true, + accessGrants: { + where: { membershipId: params.membershipId }, + select: { role: true }, + take: 1, + }, + }, + }); + if (!project) return null; + const accessRole = project.accessGrants[0]?.role ?? null; + return { + project: { + id: project.id, + workspaceId: project.workspaceId, + visibility: project.visibility, + }, + accessRole, + allowed: canPerformProjectAction({ + membershipRole: params.membershipRole, + visibility: project.visibility, + accessRole, + action: params.action, + }), + }; +} + +/** + * Assert project authority. READ denials intentionally use NOT_FOUND so a + * restricted project's existence is not disclosed to an untrusted member. + */ +export async function assertProjectAction( + db: ProjectAuthorizationDb, + params: Parameters[1], +): Promise { + const decision = await resolveProjectDecision(db, params); + if (!decision || !decision.allowed) { + throw new TRPCError({ + code: params.action === "READ" ? "NOT_FOUND" : "FORBIDDEN", + message: + params.action === "READ" + ? "Project not found." + : "You do not have permission to modify this project.", + }); + } + return decision; +} + /** * External credentials are a separate authorization layer. A Forge role or * project grant never implies permission to use a GitHub/OAuth credential; * the resolved integration grant must explicitly contain the capability too. */ -export type IntegrationCapability = "READ" | "IMPORT" | "LINK" | "SYNC" | "WRITE" | "ADMIN"; export type IntegrationAction = IntegrationCapability; const INTEGRATION_PROJECT_ACTION: Record = { diff --git a/tests/unit/authorization.test.ts b/tests/unit/authorization.test.ts index 1ae80312..dbc3abdd 100644 --- a/tests/unit/authorization.test.ts +++ b/tests/unit/authorization.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + assertProjectAction, assertWorkspaceAction, + buildProjectAccessWhere, canPerformIntegrationAction, canPerformProjectAction, canPerformWorkspaceAction, } from "@/server/services/authorization"; +import { ProjectAccessRole, ProjectVisibility } from "@prisma/client"; describe("workspace authorization", () => { it("allows every member role to read the workspace", () => { @@ -93,6 +96,59 @@ describe("project authorization policy", () => { }), ).toBe(true); }); + + it("builds the same tenant-scoped predicate used by project lists", () => { + expect( + buildProjectAccessWhere({ + workspaceId: "workspace-1", + membershipId: "membership-1", + membershipRole: "MEMBER", + action: "READ", + }), + ).toEqual({ + workspaceId: "workspace-1", + OR: [ + { visibility: ProjectVisibility.WORKSPACE }, + { + accessGrants: { + some: { + membershipId: "membership-1", + role: { + in: [ + ProjectAccessRole.VIEWER, + ProjectAccessRole.CONTRIBUTOR, + ProjectAccessRole.MANAGER, + ], + }, + }, + }, + }, + ], + }); + }); + + it("hides a restricted project from a reader without a grant", async () => { + const db = { + project: { + findFirst: async () => ({ + id: "project-1", + workspaceId: "workspace-1", + visibility: ProjectVisibility.RESTRICTED, + accessGrants: [], + }), + }, + } as unknown as Parameters[0]; + + await expect( + assertProjectAction(db, { + workspaceId: "workspace-1", + membershipId: "membership-1", + membershipRole: "MEMBER", + projectId: "project-1", + action: "READ", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); }); describe("integration authorization policy", () => { From 919c3454f8dff1e25f1a6f694c1fb83bda2c7741 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:43:17 -0400 Subject: [PATCH 02/14] fix: enforce restricted project secondary surfaces --- ...tricted-project-secondary-surfaces.test.ts | 194 ++++++++++++++++++ src/server/routers/comment.ts | 72 ++++++- src/server/routers/label.ts | 10 + src/server/routers/relation.ts | 108 ++++++++-- src/server/routers/timeEntry.ts | 99 ++++++++- 5 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 src/server/routers/__tests__/restricted-project-secondary-surfaces.test.ts diff --git a/src/server/routers/__tests__/restricted-project-secondary-surfaces.test.ts b/src/server/routers/__tests__/restricted-project-secondary-surfaces.test.ts new file mode 100644 index 00000000..b4340698 --- /dev/null +++ b/src/server/routers/__tests__/restricted-project-secondary-surfaces.test.ts @@ -0,0 +1,194 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { ProjectAccessRole, ProjectVisibility, RelationKind } from "@prisma/client"; +import { commentRouter } from "@/server/routers/comment"; +import { labelRouter } from "@/server/routers/label"; +import { relationRouter } from "@/server/routers/relation"; +import { timeEntryRouter } from "@/server/routers/timeEntry"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function setup() { + const fixture = await createWorkspaceFixture({ keyPrefix: "RPS" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const member = await prisma.membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: fixture.secondUser.id, + workspaceId: fixture.workspace.id, + }, + }, + }); + const restrictedProject = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "RSTR", + name: "Restricted", + visibility: ProjectVisibility.RESTRICTED, + createdById: fixture.user.id, + }, + }); + const publicProject = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "PUB", + name: "Workspace visible", + createdById: fixture.user.id, + }, + }); + const restrictedIssue = await createIssue(fixture, { + title: "Restricted issue", + projectId: restrictedProject.id, + }); + const publicIssue = await createIssue(fixture, { + title: "Public issue", + projectId: publicProject.id, + }); + const memberCtx = await buildContext(fixture, { asUserId: fixture.secondUser.id }); + return { + fixture, + member, + memberCtx, + restrictedProject, + restrictedIssue, + publicIssue, + }; +} + +describe("restricted project secondary surfaces", () => { + it("hides comment reads and rejects comment and label writes without contribute access", async () => { + const { fixture, memberCtx, restrictedIssue } = await setup(); + const prisma = getPrisma(); + const comment = await prisma.comment.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: restrictedIssue.id, + authorId: fixture.user.id, + body: "private details", + }, + }); + const label = await prisma.label.create({ + data: { workspaceId: fixture.workspace.id, name: "restricted-test", color: "#123456" }, + }); + const comments = commentRouter.createCaller(memberCtx); + const labels = labelRouter.createCaller(memberCtx); + + await expect(comments.listForIssue({ issueId: restrictedIssue.id })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(comments.history({ commentId: comment.id })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect( + comments.create({ issueId: restrictedIssue.id, body: "unauthorized" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + labels.setForIssue({ issueId: restrictedIssue.id, labelIds: [label.id] }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("requires access to both relation endpoints and omits inaccessible targets from reads", async () => { + const { + fixture, + member, + memberCtx, + restrictedProject, + restrictedIssue, + publicIssue, + } = await setup(); + const prisma = getPrisma(); + const relation = await prisma.issueRelation.create({ + data: { + workspaceId: fixture.workspace.id, + fromIssueId: publicIssue.id, + toIssueId: restrictedIssue.id, + kind: RelationKind.RELATES_TO, + }, + }); + const caller = relationRouter.createCaller(memberCtx); + + const visible = await caller.listForIssue({ issueId: publicIssue.id }); + expect(visible.RELATES_TO).toEqual([]); + await expect( + caller.add({ + fromIssueId: publicIssue.id, + toIssueId: restrictedIssue.id, + kind: RelationKind.DUPLICATES, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(caller.remove({ relationId: relation.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + + await prisma.projectAccess.create({ + data: { + workspaceId: fixture.workspace.id, + projectId: restrictedProject.id, + membershipId: member.id, + role: ProjectAccessRole.CONTRIBUTOR, + grantedById: fixture.user.id, + }, + }); + await expect( + caller.add({ + fromIssueId: publicIssue.id, + toIssueId: restrictedIssue.id, + kind: RelationKind.DUPLICATES, + }), + ).resolves.toMatchObject({ relation: { kind: RelationKind.DUPLICATES } }); + }); + + it("filters time reads and summaries and rejects writes for inaccessible issues", async () => { + const { fixture, memberCtx, restrictedIssue, publicIssue } = await setup(); + const prisma = getPrisma(); + const now = new Date(); + await prisma.timeEntry.createMany({ + data: [ + { + workspaceId: fixture.workspace.id, + userId: fixture.secondUser.id, + issueId: publicIssue.id, + startedAt: new Date(now.getTime() - 60 * 60 * 1000), + endedAt: now, + }, + { + workspaceId: fixture.workspace.id, + userId: fixture.secondUser.id, + issueId: restrictedIssue.id, + startedAt: new Date(now.getTime() - 30 * 60 * 1000), + endedAt: now, + }, + ], + }); + const caller = timeEntryRouter.createCaller(memberCtx); + + const rows = await caller.list(); + expect(rows.map((row) => row.issueId)).toEqual([publicIssue.id]); + const summary = await caller.summary({ + from: new Date(now.getTime() - 2 * 60 * 60 * 1000), + to: new Date(now.getTime() + 60_000), + groupBy: "issue", + }); + expect(summary.totalMinutes).toBe(60); + expect(summary.buckets.map((bucket) => bucket.key)).toEqual([publicIssue.id]); + await expect(caller.start({ issueId: restrictedIssue.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); diff --git a/src/server/routers/comment.ts b/src/server/routers/comment.ts index 3c262303..08e0c018 100644 --- a/src/server/routers/comment.ts +++ b/src/server/routers/comment.ts @@ -25,6 +25,7 @@ import { } from "@/server/services/issue-watchers"; import { createActionRequest } from "@/server/services/action-request-service"; import { resolveAgentRequests, type ParsedAgentRequest } from "@/lib/agent-request-parser"; +import { assertProjectAction } from "@/server/services/authorization"; const STATUS_REVISION_CAP = 50; /** @@ -103,11 +104,20 @@ export const commentRouter = router({ .query(async ({ ctx, input }) => { const issue = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) { throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); } + if (issue.projectId) { + await assertProjectAction(ctx.db, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "READ", + }); + } const where = { workspaceId: ctx.workspaceId, @@ -199,12 +209,22 @@ export const commentRouter = router({ number: true, title: true, assignedAgentId: true, + projectId: true, workspace: { select: { key: true } }, }, }); if (!issue) { throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); } + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } const explicitAgentRequests: ParsedAgentRequest[] = input.agentRequests?.map((r) => ({ @@ -502,11 +522,20 @@ export const commentRouter = router({ if (existing.issueId) { const issue = await ctx.db.issue.findFirst({ where: { id: existing.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) { throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); } + if (issue.projectId) { + await assertProjectAction(ctx.db, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } } // No-op when the body hasn't actually changed. Keeps the // history clean — the UI's edit-and-immediately-save path @@ -672,6 +701,7 @@ export const commentRouter = router({ updatedAt: true, revisions: true, kind: true, + issueId: true, }, }); if (!row) { @@ -680,6 +710,22 @@ export const commentRouter = router({ message: "Comment not found in this workspace.", }); } + if (row.issueId) { + const issue = await ctx.db.issue.findFirst({ + where: { id: row.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); + if (issue.projectId) { + await assertProjectAction(ctx.db, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "READ", + }); + } + } const revisions = Array.isArray(row.revisions) ? (row.revisions as Prisma.JsonArray) : []; return { id: row.id, @@ -708,11 +754,20 @@ export const commentRouter = router({ if (existing.issueId) { const issue = await tx.issue.findFirst({ where: { id: existing.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) { throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); } + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } } const deleted = await tx.comment.update({ where: { id: existing.id }, @@ -769,11 +824,20 @@ export const commentRouter = router({ return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) { throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); } + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } // Open or touch the AgentRun first — every status upsert is also // a heartbeat for the run, so the freshness clock resets and the // live pulse strip stays warm even on long-running steps. diff --git a/src/server/routers/label.ts b/src/server/routers/label.ts index fdeea20a..9804bac7 100644 --- a/src/server/routers/label.ts +++ b/src/server/routers/label.ts @@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server"; import { EventKind } from "@prisma/client"; import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; +import { assertProjectAction } from "@/server/services/authorization"; const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); @@ -61,6 +62,15 @@ export const labelRouter = router({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, include: { labels: { select: { labelId: true } } }, }); + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } const labelIds = Array.from(new Set(input.labelIds)); if (labelIds.length > 0) { const labelCount = await tx.label.count({ diff --git a/src/server/routers/relation.ts b/src/server/routers/relation.ts index cfc3c427..52acd66a 100644 --- a/src/server/routers/relation.ts +++ b/src/server/routers/relation.ts @@ -1,8 +1,13 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { EventKind, RelationKind } from "@prisma/client"; +import { EventKind, RelationKind, type Prisma } from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; +import { + assertProjectAction, + buildProjectAccessWhere, + type ProjectAction, +} from "@/server/services/authorization"; /** * Issue relations — directed, typed links between two issues. @@ -45,6 +50,27 @@ export const graphForIssueInput = z.object({ /** Hard cap so a pathological dependency web can't blow up the payload. */ const GRAPH_MAX_NODES = 60; +function issueAccessWhere( + ctx: { workspaceId: string; membership: { id: string; role: Parameters[0]["membershipRole"] } }, + action: ProjectAction, +): Prisma.IssueWhereInput { + return { + OR: [ + { projectId: null }, + { + project: { + is: buildProjectAccessWhere({ + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + action, + }), + }, + }, + ], + }; +} + export const relationRouter = router({ add: workspaceProcedure.input(addInput).mutation(async ({ ctx, input }) => { if (input.fromIssueId === input.toIssueId) { @@ -61,11 +87,11 @@ export const relationRouter = router({ const [from, to] = await Promise.all([ tx.issue.findFirst({ where: { id: input.fromIssueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }), tx.issue.findFirst({ where: { id: input.toIssueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }), ]); if (!from || !to) { @@ -74,6 +100,17 @@ export const relationRouter = router({ message: "Both issues must belong to this workspace.", }); } + for (const issue of [from, to]) { + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } + } const existing = await tx.issueRelation.findUnique({ where: { @@ -152,8 +189,8 @@ export const relationRouter = router({ const relation = await tx.issueRelation.findFirst({ where: { id: input.relationId, workspaceId: ctx.workspaceId }, include: { - fromIssue: { select: { deletedAt: true } }, - toIssue: { select: { deletedAt: true } }, + fromIssue: { select: { deletedAt: true, projectId: true } }, + toIssue: { select: { deletedAt: true, projectId: true } }, }, }); if (!relation) { @@ -165,6 +202,17 @@ export const relationRouter = router({ message: "Restore archived issues before changing their relationships.", }); } + for (const issue of [relation.fromIssue, relation.toIssue]) { + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } + } await tx.issueRelation.delete({ where: { id: relation.id } }); @@ -209,9 +257,18 @@ export const relationRouter = router({ // workspaceId in their unique index). const issue = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); + if (issue.projectId) { + await assertProjectAction(ctx.db, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "READ", + }); + } // We only look at outgoing edges. BLOCKS/BLOCKED_BY already have a // mirror row on the other side (written in `add`), so every @@ -221,7 +278,7 @@ export const relationRouter = router({ where: { workspaceId: ctx.workspaceId, fromIssueId: input.issueId, - toIssue: { deletedAt: null }, + toIssue: { deletedAt: null, ...issueAccessWhere(ctx, "READ") }, }, include: { toIssue: { @@ -297,9 +354,19 @@ export const relationRouter = router({ graphForIssue: workspaceProcedure.input(graphForIssueInput).query(async ({ ctx, input }) => { const root = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!root) throw new TRPCError({ code: "NOT_FOUND" }); + if (root.projectId) { + await assertProjectAction(ctx.db, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: root.projectId, + action: "READ", + }); + } + const visibleIssueWhere = issueAccessWhere(ctx, "READ"); type Edge = { id: string; @@ -329,17 +396,29 @@ export const relationRouter = router({ workspaceId: ctx.workspaceId, kind: RelationKind.BLOCKS, OR: [{ fromIssueId: { in: frontier } }, { toIssueId: { in: frontier } }], + fromIssue: visibleIssueWhere, + toIssue: visibleIssueWhere, }, select: { fromIssueId: true, toIssueId: true }, }), // The frontier issues' own parents (child → up to parent). ctx.db.issue.findMany({ - where: { id: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, + where: { + id: { in: frontier }, + workspaceId: ctx.workspaceId, + deletedAt: null, + ...visibleIssueWhere, + }, select: { id: true, parentId: true }, }), // The frontier issues' children (parent → down to child). ctx.db.issue.findMany({ - where: { parentId: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, + where: { + parentId: { in: frontier }, + workspaceId: ctx.workspaceId, + deletedAt: null, + ...visibleIssueWhere, + }, select: { id: true, parentId: true }, }), // A frontier issue can be the materialized form of a plan step. @@ -379,7 +458,7 @@ export const relationRouter = router({ workspaceId: ctx.workspaceId, planId: { in: planIds }, issueId: { not: null }, - issue: { is: { deletedAt: null } }, + issue: { is: { deletedAt: null, ...visibleIssueWhere } }, }, select: { id: true, issueId: true, dependsOnStepIds: true }, }); @@ -418,7 +497,12 @@ export const relationRouter = router({ } const issues = await ctx.db.issue.findMany({ - where: { id: { in: [...nodeIds] }, workspaceId: ctx.workspaceId, deletedAt: null }, + where: { + id: { in: [...nodeIds] }, + workspaceId: ctx.workspaceId, + deletedAt: null, + ...visibleIssueWhere, + }, select: { id: true, number: true, diff --git a/src/server/routers/timeEntry.ts b/src/server/routers/timeEntry.ts index dba97770..6eab62fa 100644 --- a/src/server/routers/timeEntry.ts +++ b/src/server/routers/timeEntry.ts @@ -4,6 +4,10 @@ import { EventKind } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; +import { + assertProjectAction, + buildProjectAccessWhere, +} from "@/server/services/authorization"; /** * Time tracking — per-user, workspace-scoped duration rows. @@ -90,6 +94,38 @@ function csvEscape(value: string | number | null | undefined): string { return s; } +function visibleIssueWhere(ctx: { + workspaceId: string; + membership: { + id: string; + role: Parameters[0]["membershipRole"]; + }; +}): Prisma.IssueWhereInput { + return { + OR: [ + { projectId: null }, + { + project: { + is: buildProjectAccessWhere({ + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + action: "READ", + }), + }, + }, + ], + }; +} + +function visibleTimeEntryWhere( + ctx: Parameters[0], +): Prisma.TimeEntryWhereInput { + return { + OR: [{ issueId: null }, { issue: { is: visibleIssueWhere(ctx) } }], + }; +} + export const timeEntryRouter = router({ start: workspaceProcedure.input(startInput).mutation(async ({ ctx, input }) => { return ctx.db.$transaction(async (tx) => { @@ -110,11 +146,20 @@ export const timeEntryRouter = router({ if (input.issueId) { const issue = await tx.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, + select: { id: true, projectId: true }, }); if (!issue) { throw new TRPCError({ code: "BAD_REQUEST", message: "Issue not found in workspace." }); } + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } } const entry = await tx.timeEntry.create({ @@ -161,6 +206,22 @@ export const timeEntryRouter = router({ }, }); if (!entry) throw new TRPCError({ code: "NOT_FOUND" }); + if (entry.issueId) { + const issue = await tx.issue.findFirst({ + where: { id: entry.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } + } if (entry.endedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "Entry is already stopped." }); } @@ -203,6 +264,7 @@ export const timeEntryRouter = router({ workspaceId: ctx.workspaceId, userId: ctx.session.user.id, endedAt: null, + ...visibleTimeEntryWhere(ctx), }, include: { issue: { @@ -217,6 +279,7 @@ export const timeEntryRouter = router({ const where: Prisma.TimeEntryWhereInput = { workspaceId: ctx.workspaceId, userId, + ...visibleTimeEntryWhere(ctx), ...(input.issueId ? { issueId: input.issueId } : {}), ...(input.billable !== undefined ? { billable: input.billable } : {}), ...(input.from || input.to @@ -257,6 +320,22 @@ export const timeEntryRouter = router({ }, }); if (!entry) throw new TRPCError({ code: "NOT_FOUND" }); + if (entry.issueId) { + const issue = await tx.issue.findFirst({ + where: { id: entry.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } + } return tx.timeEntry.update({ where: { id: entry.id }, data: patch }); }); }), @@ -271,6 +350,22 @@ export const timeEntryRouter = router({ }, }); if (!entry) throw new TRPCError({ code: "NOT_FOUND" }); + if (entry.issueId) { + const issue = await tx.issue.findFirst({ + where: { id: entry.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND" }); + if (issue.projectId) { + await assertProjectAction(tx, { + workspaceId: ctx.workspaceId, + membershipId: ctx.membership.id, + membershipRole: ctx.membership.role, + projectId: issue.projectId, + action: "CONTRIBUTE", + }); + } + } await tx.timeEntry.delete({ where: { id: entry.id } }); return { ok: true }; }); @@ -282,6 +377,7 @@ export const timeEntryRouter = router({ where: { workspaceId: ctx.workspaceId, userId, + ...visibleTimeEntryWhere(ctx), startedAt: { gte: input.from, lte: input.to }, endedAt: { not: null }, }, @@ -335,6 +431,7 @@ export const timeEntryRouter = router({ where: { workspaceId: ctx.workspaceId, userId, + ...visibleTimeEntryWhere(ctx), startedAt: { gte: input.from, lte: input.to }, }, include: { From 5daf5142fa45502d31ee3ccfa8e5c756de1f9be2 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:44:18 -0400 Subject: [PATCH 03/14] fix: preserve integration grant audit history --- .../migration.sql | 2 -- prisma/schema.prisma | 2 -- 2 files changed, 4 deletions(-) diff --git a/prisma/migrations/20260825190000_project_integration_authorization/migration.sql b/prisma/migrations/20260825190000_project_integration_authorization/migration.sql index 79ec5404..ca2fb06c 100644 --- a/prisma/migrations/20260825190000_project_integration_authorization/migration.sql +++ b/prisma/migrations/20260825190000_project_integration_authorization/migration.sql @@ -194,8 +194,6 @@ ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_connectionAuthor FOREIGN KEY ("connectionAuthorizationId", "workspaceId") REFERENCES "ConnectionAuthorization"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalUserId_fkey" FOREIGN KEY ("principalUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalUserId_workspaceId_fkey" - FOREIGN KEY ("principalUserId", "workspaceId") REFERENCES "Membership"("userId", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalAgentId_workspaceId_fkey" FOREIGN KEY ("principalAgentId", "workspaceId") REFERENCES "Agent"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "IntegrationGrant" ADD CONSTRAINT "IntegrationGrant_principalApiKeyId_workspaceId_fkey" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 693dc08f..51863007 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1523,7 +1523,6 @@ model Membership { user User @relation(fields: [userId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) projectAccesses ProjectAccess[] @relation("ProjectAccessMembership") - integrationGrants IntegrationGrant[] @relation("IntegrationGrantUserMembership") @@unique([userId, workspaceId]) @@unique([id, workspaceId]) @@ -2875,7 +2874,6 @@ model IntegrationGrant { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) connectionAuthorization ConnectionAuthorization @relation(fields: [connectionAuthorizationId, workspaceId], references: [id, workspaceId], onDelete: Cascade) principalUser User? @relation("IntegrationGrantUserPrincipal", fields: [principalUserId], references: [id], onDelete: Cascade) - principalMembership Membership? @relation("IntegrationGrantUserMembership", fields: [principalUserId, workspaceId], references: [userId, workspaceId], onDelete: Cascade) principalAgent Agent? @relation("IntegrationGrantAgentPrincipal", fields: [principalAgentId, workspaceId], references: [id, workspaceId], onDelete: Cascade) principalApiKey ApiKey? @relation("IntegrationGrantApiKeyPrincipal", fields: [principalApiKeyId, workspaceId], references: [id, workspaceId], onDelete: Cascade) project Project? @relation(fields: [projectId, workspaceId], references: [id, workspaceId], onDelete: Cascade) From 9c7647e9a32b478a23a731c4cf84169cfe8d61b1 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:50:21 -0400 Subject: [PATCH 04/14] feat: enforce integration grants across GitHub workflows --- .../__tests__/integration-grant.test.ts | 130 ++++++ src/server/routers/connection-mapping.ts | 17 +- src/server/routers/connection.ts | 77 +++- src/server/routers/github.ts | 143 ++++++- src/server/routers/integration-grant.ts | 386 +++++++++++++++++ .../__tests__/github-reconciliation.test.ts | 10 +- .../github-webhook-hardening.test.ts | 22 + src/server/services/github/client.ts | 21 +- .../services/github/installation-token.ts | 10 +- src/server/services/github/linkability.ts | 173 +++++++- src/server/services/github/reconciliation.ts | 46 ++- src/server/services/github/resource-sync.ts | 25 +- src/server/services/github/webhook.ts | 45 ++ .../services/integration-authorization.ts | 389 ++++++++++++++++++ src/server/services/mcp.ts | 116 +++++- tests/unit/integration-authorization.test.ts | 72 ++++ 16 files changed, 1618 insertions(+), 64 deletions(-) create mode 100644 src/server/routers/__tests__/integration-grant.test.ts create mode 100644 src/server/routers/integration-grant.ts create mode 100644 src/server/services/integration-authorization.ts create mode 100644 tests/unit/integration-authorization.test.ts diff --git a/src/server/routers/__tests__/integration-grant.test.ts b/src/server/routers/__tests__/integration-grant.test.ts new file mode 100644 index 00000000..d53ae3d5 --- /dev/null +++ b/src/server/routers/__tests__/integration-grant.test.ts @@ -0,0 +1,130 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { IntegrationPrincipalType } from "@prisma/client"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { ensureMappingAuthorization } from "@/server/services/github/linkability"; +import { assertIntegrationAction } from "@/server/services/integration-authorization"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function setup() { + const fixture = await createWorkspaceFixture({ keyPrefix: "IG" }); + fixtures.push(fixture); + const db = getPrisma(); + const connection = await db.connection.create({ + data: { + ownerId: fixture.user.id, + provider: "GITHUB", + label: "Grant test", + status: "CONNECTED", + config: { installationId: "181" }, + }, + }); + const mapping = await db.connectionMapping.create({ + data: { + workspaceId: fixture.workspace.id, + connectionId: connection.id, + kind: "repo", + target: "acme/private", + direction: "inbound+outbound", + }, + }); + await ensureMappingAuthorization({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + userId: fixture.user.id, + }); + return { db, fixture, mapping }; +} + +describe("integration grant enforcement", () => { + it("requires owner authorization and an exact principal grant even for an owner", async () => { + const { db, fixture, mapping } = await setup(); + await expect( + assertIntegrationAction({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + principal: { type: "USER", userId: fixture.user.id }, + action: "LINK", + }), + ).resolves.toMatchObject({ mappingId: mapping.id }); + + await db.integrationGrant.updateMany({ + where: { + principalType: IntegrationPrincipalType.USER, + principalUserId: fixture.user.id, + }, + data: { revokedAt: new Date(), revokedById: fixture.user.id }, + }); + await expect( + assertIntegrationAction({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + principal: { type: "USER", userId: fixture.user.id }, + action: "READ", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("requires a separate automation principal grant", async () => { + const { db, fixture, mapping } = await setup(); + await expect( + assertIntegrationAction({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "SYNC", + }), + ).resolves.toMatchObject({ mappingId: mapping.id }); + + await db.integrationGrant.updateMany({ + where: { + principalType: IntegrationPrincipalType.WORKSPACE_AUTOMATION, + connectionAuthorization: { connectionMappingId: mapping.id }, + }, + data: { revokedAt: new Date(), revokedById: fixture.user.id }, + }); + await expect( + assertIntegrationAction({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "SYNC", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("invalidates consent when security-relevant mapping policy changes", async () => { + const { db, fixture, mapping } = await setup(); + await db.connectionMapping.update({ + where: { id: mapping.id }, + data: { target: "acme/other-private" }, + }); + await expect( + assertIntegrationAction({ + db, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + principal: { type: "USER", userId: fixture.user.id }, + action: "READ", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/src/server/routers/connection-mapping.ts b/src/server/routers/connection-mapping.ts index 67455c3f..93daf0c2 100644 --- a/src/server/routers/connection-mapping.ts +++ b/src/server/routers/connection-mapping.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import type { Prisma } from "@prisma/client"; import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; +import { ensureMappingAuthorization } from "@/server/services/github/linkability"; /** * Per-workspace **mappings** of a global Connection to concrete targets @@ -21,7 +22,9 @@ export const connectionMappingRouter = router({ where: { workspaceId: ctx.workspaceId }, orderBy: { createdAt: "asc" }, include: { - connection: { select: { id: true, provider: true, label: true, account: true, status: true } }, + connection: { + select: { id: true, provider: true, label: true, account: true, status: true }, + }, }, }), ), @@ -43,8 +46,9 @@ export const connectionMappingRouter = router({ where: { id: input.connectionId, ownerId: ctx.session.user.id }, select: { id: true }, }); - if (!conn) throw new TRPCError({ code: "FORBIDDEN", message: "Map only your own connections." }); - return ctx.db.connectionMapping.create({ + if (!conn) + throw new TRPCError({ code: "FORBIDDEN", message: "Map only your own connections." }); + const mapping = await ctx.db.connectionMapping.create({ data: { workspaceId: ctx.workspaceId, connectionId: input.connectionId, @@ -56,6 +60,13 @@ export const connectionMappingRouter = router({ config: (input.config ?? undefined) as Prisma.InputJsonValue | undefined, }, }); + await ensureMappingAuthorization({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + userId: ctx.session.user.id, + }); + return mapping; }), update: adminProcedure diff --git a/src/server/routers/connection.ts b/src/server/routers/connection.ts index 876cdb68..767583f3 100644 --- a/src/server/routers/connection.ts +++ b/src/server/routers/connection.ts @@ -71,7 +71,11 @@ export const connectionRouter = router({ get: globalProcedure.input(z.object({ id: z.string().cuid() })).query(async ({ ctx, input }) => { const c = await ctx.db.connection.findUnique({ where: { id: input.id }, - include: { mappings: { include: { workspace: { select: { id: true, slug: true, name: true, key: true } } } } }, + include: { + mappings: { + include: { workspace: { select: { id: true, slug: true, name: true, key: true } } }, + }, + }, }); if (!c || c.ownerId !== ctx.session.user.id) throw new TRPCError({ code: "NOT_FOUND" }); const { tokenEnc, config, ...rest } = c; @@ -125,7 +129,9 @@ export const connectionRouter = router({ const { id, config: configPatch, ...data } = input; // Merge over existing config so partial updates (e.g. just a new // clientSecret) don't clobber issuer/clientId; secret is re-encrypted. - const config = configPatch ? mergeConfigUpdate(readConfig(owned.config), configPatch) : undefined; + const config = configPatch + ? mergeConfigUpdate(readConfig(owned.config), configPatch) + : undefined; const row = await ctx.db.connection.update({ where: { id }, data: { ...data, config: (config ?? undefined) as Prisma.InputJsonValue | undefined }, @@ -141,9 +147,18 @@ export const connectionRouter = router({ * too (also encrypted). */ setToken: protectedProcedure - .input(z.object({ id: z.string().cuid(), token: z.string().min(1).max(8192), expiresAt: z.date().nullish() })) + .input( + z.object({ + id: z.string().cuid(), + token: z.string().min(1).max(8192), + expiresAt: z.date().nullish(), + }), + ) .mutation(async ({ ctx, input }) => { - const owned = await ctx.db.connection.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id }, select: { id: true } }); + const owned = await ctx.db.connection.findFirst({ + where: { id: input.id, ownerId: ctx.session.user.id }, + select: { id: true }, + }); if (!owned) throw new TRPCError({ code: "NOT_FOUND" }); const row = await ctx.db.connection.update({ where: { id: input.id }, @@ -166,7 +181,12 @@ export const connectionRouter = router({ * call this periodically; the procedure is enough for on-demand refresh. */ refreshIfNeeded: protectedProcedure - .input(z.object({ id: z.string().cuid(), skewSeconds: z.number().int().min(0).max(86400).default(120) })) + .input( + z.object({ + id: z.string().cuid(), + skewSeconds: z.number().int().min(0).max(86400).default(120), + }), + ) .mutation(async ({ ctx, input }) => { const row = await ctx.db.connection.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id }, @@ -212,18 +232,50 @@ export const connectionRouter = router({ where: { id: row.id }, data: { status: ConnectionStatus.DEGRADED, error: msg.slice(0, 500) }, }); - return { refreshed: false as const, reason: "refresh-failed", error: msg, status: ConnectionStatus.DEGRADED }; + return { + refreshed: false as const, + reason: "refresh-failed", + error: msg, + status: ConnectionStatus.DEGRADED, + }; } }), disconnect: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { - const owned = await ctx.db.connection.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id }, select: { id: true } }); + const owned = await ctx.db.connection.findFirst({ + where: { id: input.id, ownerId: ctx.session.user.id }, + select: { id: true }, + }); if (!owned) throw new TRPCError({ code: "NOT_FOUND" }); - const row = await ctx.db.connection.update({ - where: { id: input.id }, - data: { tokenEnc: null, status: ConnectionStatus.DISCONNECTED }, + const row = await ctx.db.$transaction(async (tx) => { + const mappings = await tx.connectionMapping.findMany({ + where: { connectionId: input.id }, + select: { id: true, authorization: { select: { id: true } } }, + }); + const authorizationIds = mappings + .map((mapping) => mapping.authorization?.id) + .filter((id): id is string => Boolean(id)); + const now = new Date(); + if (authorizationIds.length > 0) { + await tx.integrationGrant.updateMany({ + where: { connectionAuthorizationId: { in: authorizationIds }, revokedAt: null }, + data: { revokedAt: now, revokedById: ctx.session.user.id }, + }); + await tx.connectionAuthorization.updateMany({ + where: { id: { in: authorizationIds }, revokedAt: null }, + data: { revokedAt: now, revokedById: ctx.session.user.id }, + }); + } + await tx.connectionMapping.updateMany({ + where: { connectionId: input.id }, + data: { status: "paused" }, + }); + return tx.connection.update({ + where: { id: input.id }, + data: { tokenEnc: null, status: ConnectionStatus.DISCONNECTED }, + }); }); const { tokenEnc, config, ...rest } = row; return { ...rest, config: redactConfig(config), hasToken: !!tokenEnc }; @@ -232,7 +284,10 @@ export const connectionRouter = router({ delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { - const owned = await ctx.db.connection.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id }, select: { id: true } }); + const owned = await ctx.db.connection.findFirst({ + where: { id: input.id, ownerId: ctx.session.user.id }, + select: { id: true }, + }); if (!owned) throw new TRPCError({ code: "NOT_FOUND" }); await ctx.db.connection.delete({ where: { id: input.id } }); return { id: input.id, deleted: true as const }; diff --git a/src/server/routers/github.ts b/src/server/routers/github.ts index 6daee3c8..341f95dd 100644 --- a/src/server/routers/github.ts +++ b/src/server/routers/github.ts @@ -10,7 +10,10 @@ import { pullRequestSnapshot, searchGitHubIssuesAndPulls, } from "@/server/services/github/client"; -import { githubInstallationId } from "@/server/services/github/mapping-policy"; +import { + githubInstallationId, + readGitHubMappingConfig, +} from "@/server/services/github/mapping-policy"; import { importGitHubIssue, linkGitHubUrlToIssue, @@ -27,10 +30,35 @@ import { resolveRepoLinkability, } from "@/server/services/github/linkability"; import { parseGitHubUrl, splitRepoFullName } from "@/server/services/github/url"; -import { EXTERNAL_LINK_KINDS, GITHUB_RESOURCE_TYPES, type GitHubResourceSnapshot } from "@/server/services/github/types"; +import { + EXTERNAL_LINK_KINDS, + GITHUB_RESOURCE_TYPES, + type GitHubResourceSnapshot, +} from "@/server/services/github/types"; +import { + assertIntegrationAction, + type IntegrationPrincipal, +} from "@/server/services/integration-authorization"; const linkKindSchema = z.enum(EXTERNAL_LINK_KINDS); +function sessionPrincipal(userId: string): IntegrationPrincipal { + return { type: "USER", userId }; +} + +async function issueProjectId( + db: Parameters[0]["db"], + workspaceId: string, + issueId: string, +): Promise { + const issue = await db.issue.findFirst({ + where: { id: issueId, workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new TRPCError({ code: "NOT_FOUND", message: "Issue not found." }); + return issue.projectId; +} + /** `owner/repo` shape guard — keeps malformed input a clean 400, not a 500. */ const repoFullNameSchema = z .string() @@ -58,6 +86,7 @@ export const githubRouter = router({ repoFullName: repoFullNameSchema.optional(), number: z.number().int().positive().optional(), mappingId: z.string().cuid().optional(), + issueId: z.string().cuid().optional(), }) .refine((v) => !!v.url || (!!v.repoFullName && !!v.number), { message: "Provide a url, or repoFullName + number.", @@ -76,10 +105,22 @@ export const githubRouter = router({ mappingId: input.mappingId, repoFullName: ref.repoFullName, }); + await assertIntegrationAction({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: sessionPrincipal(ctx.session.user.id), + action: "READ", + projectId: input.issueId + ? await issueProjectId(ctx.db, ctx.workspaceId, input.issueId) + : null, + }); const installationId = githubInstallationId(mapping.connection); + const githubAppId = mapping.authorization?.githubAppId ?? null; if (ref.type === "PULL_REQUEST") { const pr = await getGitHubPullRequest({ installationId, + githubAppId, owner: ref.owner, repo: ref.repo, number: ref.number, @@ -88,6 +129,7 @@ export const githubRouter = router({ } const issue = await getGitHubIssue({ installationId, + githubAppId, owner: ref.owner, repo: ref.repo, number: ref.number, @@ -97,6 +139,7 @@ export const githubRouter = router({ if (issue.pull_request) { const pr = await getGitHubPullRequest({ installationId, + githubAppId, owner: ref.owner, repo: ref.repo, number: ref.number, @@ -126,7 +169,9 @@ export const githubRouter = router({ /** Active repo mappings — browse-mode repo picker + agent discovery. */ listMappings: workspaceProcedure - .input(z.object({ includePaused: z.boolean().default(false) }).default({ includePaused: false })) + .input( + z.object({ includePaused: z.boolean().default(false) }).default({ includePaused: false }), + ) .query(({ ctx, input }) => listGitHubRepoMappings({ db: ctx.db, @@ -238,8 +283,23 @@ export const githubRouter = router({ mappingId: z.string().cuid().optional(), }), ) - .mutation(({ ctx, input }) => - linkGitHubUrlToIssue({ + .mutation(async ({ ctx, input }) => { + const parsed = parseGitHubUrl(input.url); + const mapping = await resolveGitHubRepoMapping({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: input.mappingId, + repoFullName: parsed.repoFullName, + }); + await assertIntegrationAction({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: sessionPrincipal(ctx.session.user.id), + action: "LINK", + projectId: await issueProjectId(ctx.db, ctx.workspaceId, input.issueId), + }); + return linkGitHubUrlToIssue({ db: ctx.db, workspaceId: ctx.workspaceId, issueId: input.issueId, @@ -252,8 +312,8 @@ export const githubRouter = router({ ip: ctx.ip, userAgent: ctx.userAgent, }, - }), - ), + }); + }), importIssue: workspaceProcedure .input( @@ -272,8 +332,25 @@ export const githubRouter = router({ message: "Provide a url, or repoFullName + number.", }), ) - .mutation(({ ctx, input }) => - importGitHubIssue({ + .mutation(async ({ ctx, input }) => { + const parsed = input.url ? parseGitHubUrl(input.url) : null; + const mapping = await resolveGitHubRepoMapping({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: input.mappingId, + repoFullName: parsed?.repoFullName ?? input.repoFullName, + }); + const projectId = + input.projectId ?? readGitHubMappingConfig(mapping.config).defaultProjectId ?? null; + await assertIntegrationAction({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: sessionPrincipal(ctx.session.user.id), + action: "IMPORT", + projectId, + }); + return importGitHubIssue({ db: ctx.db, workspaceId: ctx.workspaceId, mappingId: input.mappingId, @@ -281,7 +358,7 @@ export const githubRouter = router({ repoFullName: input.repoFullName, resourceType: input.resourceType, number: input.number, - projectId: input.projectId, + projectId, labelIds: input.labelIds, queue: input.queue, actor: { @@ -290,13 +367,37 @@ export const githubRouter = router({ ip: ctx.ip, userAgent: ctx.userAgent, }, - }), - ), + }); + }), sync: workspaceProcedure .input(z.object({ externalResourceId: z.string().cuid() })) - .mutation(({ ctx, input }) => - syncGitHubExternalResource({ + .mutation(async ({ ctx, input }) => { + const resource = await ctx.db.externalResource.findFirst({ + where: { id: input.externalResourceId, workspaceId: ctx.workspaceId, provider: "GITHUB" }, + select: { + connectionMappingId: true, + links: { select: { issue: { select: { projectId: true } } } }, + }, + }); + if (!resource?.connectionMappingId) + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "GitHub resource has no active credential mapping.", + }); + const projectIds = new Set(resource.links.map((link) => link.issue.projectId)); + if (projectIds.size === 0) projectIds.add(null); + for (const projectId of projectIds) { + await assertIntegrationAction({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: resource.connectionMappingId, + principal: sessionPrincipal(ctx.session.user.id), + action: "SYNC", + projectId, + }); + } + return syncGitHubExternalResource({ db: ctx.db, workspaceId: ctx.workspaceId, externalResourceId: input.externalResourceId, @@ -306,8 +407,8 @@ export const githubRouter = router({ ip: ctx.ip, userAgent: ctx.userAgent, }, - }), - ), + }); + }), search: workspaceProcedure .input( @@ -315,6 +416,7 @@ export const githubRouter = router({ mappingId: z.string().cuid(), query: z.string().min(1).max(200), type: z.enum(["issue", "pr"]).optional(), + projectId: z.string().cuid().nullable().optional(), }), ) .query(async ({ ctx, input }) => { @@ -323,8 +425,17 @@ export const githubRouter = router({ workspaceId: ctx.workspaceId, mappingId: input.mappingId, }); + await assertIntegrationAction({ + db: ctx.db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: sessionPrincipal(ctx.session.user.id), + action: "READ", + projectId: input.projectId ?? null, + }); return searchGitHubIssuesAndPulls({ installationId: githubInstallationId(mapping.connection), + githubAppId: mapping.authorization?.githubAppId ?? null, repoFullName: mapping.target, query: input.query, type: input.type, diff --git a/src/server/routers/integration-grant.ts b/src/server/routers/integration-grant.ts new file mode 100644 index 00000000..5f761d65 --- /dev/null +++ b/src/server/routers/integration-grant.ts @@ -0,0 +1,386 @@ +import { z } from "zod"; +import { + ConnectionProvider, + EventKind, + IntegrationCapability, + IntegrationCredentialSource, + IntegrationGrantScope, + IntegrationPrincipalType, + Role, +} from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { adminProcedure, router, workspaceProcedure } from "@/server/trpc"; +import { recordChange } from "@/server/audit"; +import { connectionAuthorizationDigest } from "@/server/services/integration-authorization"; +import { githubInstallationId } from "@/server/services/github/mapping-policy"; + +const capabilities = z.array(z.nativeEnum(IntegrationCapability)).min(1); + +const principalInput = z.discriminatedUnion("type", [ + z.object({ type: z.literal(IntegrationPrincipalType.USER), userId: z.string().cuid() }), + z.object({ type: z.literal(IntegrationPrincipalType.AGENT), agentId: z.string().cuid() }), + z.object({ type: z.literal(IntegrationPrincipalType.API_KEY), apiKeyId: z.string().cuid() }), + z.object({ type: z.literal(IntegrationPrincipalType.WORKSPACE_AUTOMATION) }), +]); + +function isSubset(values: IntegrationCapability[], ceiling: IntegrationCapability[]): boolean { + const allowed = new Set(ceiling); + return values.every((value) => allowed.has(value)); +} + +export const integrationGrantRouter = router({ + list: adminProcedure.query(({ ctx }) => + ctx.db.connectionAuthorization.findMany({ + where: { workspaceId: ctx.workspaceId }, + include: { + connectionMapping: { + select: { + id: true, + kind: true, + target: true, + direction: true, + status: true, + connection: { + select: { id: true, provider: true, label: true, account: true, ownerId: true }, + }, + }, + }, + githubApp: { select: { id: true, name: true, installationId: true } }, + grants: true, + }, + orderBy: { createdAt: "asc" }, + }), + ), + + /** Credential-owner consent. Workspace admins cannot authorize a colleague's personal credential. */ + authorize: workspaceProcedure + .input( + z.object({ + mappingId: z.string().cuid(), + credentialSource: z.nativeEnum(IntegrationCredentialSource), + githubAppId: z.string().cuid().nullable().optional(), + capabilities, + }), + ) + .mutation(async ({ ctx, input }) => + ctx.db.$transaction(async (tx) => { + const mapping = await tx.connectionMapping.findFirst({ + where: { id: input.mappingId, workspaceId: ctx.workspaceId }, + include: { connection: true, authorization: true }, + }); + if (!mapping) + throw new TRPCError({ code: "NOT_FOUND", message: "Integration mapping not found." }); + + let githubAppId: string | null = null; + if (input.credentialSource === IntegrationCredentialSource.USER_CONNECTION) { + if (mapping.connection.ownerId !== ctx.session.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the credential owner can authorize this mapping.", + }); + } + if (input.githubAppId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A personal credential cannot bind a workspace GitHub App.", + }); + } + } else { + if (ctx.membership.role !== Role.OWNER && ctx.membership.role !== Role.ADMIN) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Workspace admin access is required to authorize a workspace GitHub App.", + }); + } + if (mapping.connection.provider !== ConnectionProvider.GITHUB || !input.githubAppId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace App authorization requires an exact GitHub App.", + }); + } + const app = await tx.githubApp.findFirst({ + where: { + id: input.githubAppId, + workspaceId: ctx.workspaceId, + installationId: { not: null }, + }, + select: { id: true, installationId: true }, + }); + if ( + !app?.installationId || + app.installationId !== githubInstallationId(mapping.connection) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "GitHub App does not own this mapping's installation.", + }); + } + githubAppId = app.id; + } + + const digest = connectionAuthorizationDigest(mapping); + const prior = mapping.authorization; + const now = new Date(); + const authorization = prior + ? await tx.connectionAuthorization.update({ + where: { id: prior.id }, + data: { + credentialSource: input.credentialSource, + githubAppId, + capabilities: input.capabilities, + authorizedById: ctx.session.user.id, + authorizationDigest: digest, + authorizedAt: now, + revokedById: null, + revokedAt: null, + }, + }) + : await tx.connectionAuthorization.create({ + data: { + workspaceId: ctx.workspaceId, + connectionMappingId: mapping.id, + credentialSource: input.credentialSource, + githubAppId, + capabilities: input.capabilities, + authorizedById: ctx.session.user.id, + authorizationDigest: digest, + authorizedAt: now, + }, + }); + + if (prior && prior.authorizationDigest !== digest) { + await tx.integrationGrant.updateMany({ + where: { connectionAuthorizationId: prior.id, revokedAt: null }, + data: { revokedAt: now, revokedById: ctx.session.user.id }, + }); + } + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + entity: "ConnectionAuthorization", + entityId: authorization.id, + action: prior ? "reauthorize" : "authorize", + before: prior ?? undefined, + after: authorization, + eventKind: EventKind.INTEGRATION_AUTHORIZATION_CHANGED, + subjectType: "connection-authorization", + subjectId: authorization.id, + payload: { mappingId: mapping.id, credentialSource: input.credentialSource }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return authorization; + }), + ), + + revokeAuthorization: workspaceProcedure + .input(z.object({ id: z.string().cuid() })) + .mutation(async ({ ctx, input }) => + ctx.db.$transaction(async (tx) => { + const row = await tx.connectionAuthorization.findFirst({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + include: { + connectionMapping: { include: { connection: { select: { ownerId: true } } } }, + }, + }); + if (!row) throw new TRPCError({ code: "NOT_FOUND" }); + const isAdmin = ctx.membership.role === Role.OWNER || ctx.membership.role === Role.ADMIN; + if (!isAdmin && row.connectionMapping.connection.ownerId !== ctx.session.user.id) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const now = new Date(); + const updated = await tx.connectionAuthorization.update({ + where: { id: row.id }, + data: { revokedAt: now, revokedById: ctx.session.user.id }, + }); + await tx.integrationGrant.updateMany({ + where: { connectionAuthorizationId: row.id, revokedAt: null }, + data: { revokedAt: now, revokedById: ctx.session.user.id }, + }); + await tx.connectionMapping.update({ + where: { id: row.connectionMappingId }, + data: { status: "paused" }, + }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + entity: "ConnectionAuthorization", + entityId: row.id, + action: "revoke", + before: row, + after: updated, + eventKind: EventKind.INTEGRATION_AUTHORIZATION_CHANGED, + subjectType: "connection-authorization", + subjectId: row.id, + payload: { mappingId: row.connectionMappingId }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return updated; + }), + ), + + upsertGrant: adminProcedure + .input( + z.object({ + connectionAuthorizationId: z.string().cuid(), + principal: principalInput, + scope: z.nativeEnum(IntegrationGrantScope), + projectId: z.string().cuid().nullable().optional(), + capabilities, + }), + ) + .mutation(async ({ ctx, input }) => + ctx.db.$transaction(async (tx) => { + const authorization = await tx.connectionAuthorization.findFirst({ + where: { + id: input.connectionAuthorizationId, + workspaceId: ctx.workspaceId, + revokedAt: null, + }, + }); + if (!authorization) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Active credential authorization not found.", + }); + if (!isSubset(input.capabilities, authorization.capabilities)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Grant exceeds the credential owner's authorized capability ceiling.", + }); + } + const projectId = + input.scope === IntegrationGrantScope.PROJECT ? (input.projectId ?? null) : null; + if (input.scope === IntegrationGrantScope.PROJECT && !projectId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project-scoped grants require projectId.", + }); + } + if (projectId) { + const project = await tx.project.findFirst({ + where: { id: projectId, workspaceId: ctx.workspaceId }, + select: { id: true }, + }); + if (!project) throw new TRPCError({ code: "NOT_FOUND", message: "Project not found." }); + } + + let principalUserId: string | null = null; + let principalAgentId: string | null = null; + let principalApiKeyId: string | null = null; + if (input.principal.type === IntegrationPrincipalType.USER) { + const membership = await tx.membership.findUnique({ + where: { + userId_workspaceId: { userId: input.principal.userId, workspaceId: ctx.workspaceId }, + }, + select: { id: true }, + }); + if (!membership) + throw new TRPCError({ code: "NOT_FOUND", message: "Workspace member not found." }); + principalUserId = input.principal.userId; + } else if (input.principal.type === IntegrationPrincipalType.AGENT) { + const agent = await tx.agent.findFirst({ + where: { id: input.principal.agentId, workspaceId: ctx.workspaceId }, + select: { id: true }, + }); + if (!agent) + throw new TRPCError({ code: "NOT_FOUND", message: "Workspace agent not found." }); + principalAgentId = agent.id; + } else if (input.principal.type === IntegrationPrincipalType.API_KEY) { + const key = await tx.apiKey.findFirst({ + where: { id: input.principal.apiKeyId, workspaceId: ctx.workspaceId, revokedAt: null }, + select: { id: true }, + }); + if (!key) + throw new TRPCError({ code: "NOT_FOUND", message: "Active API key not found." }); + principalApiKeyId = key.id; + } + + const existing = await tx.integrationGrant.findFirst({ + where: { + connectionAuthorizationId: authorization.id, + principalType: input.principal.type, + principalUserId, + principalAgentId, + principalApiKeyId, + scope: input.scope, + projectId, + revokedAt: null, + }, + }); + const grant = existing + ? await tx.integrationGrant.update({ + where: { id: existing.id }, + data: { capabilities: input.capabilities, grantedById: ctx.session.user.id }, + }) + : await tx.integrationGrant.create({ + data: { + workspaceId: ctx.workspaceId, + connectionAuthorizationId: authorization.id, + principalType: input.principal.type, + principalUserId, + principalAgentId, + principalApiKeyId, + scope: input.scope, + projectId, + capabilities: input.capabilities, + grantedById: ctx.session.user.id, + }, + }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + entity: "IntegrationGrant", + entityId: grant.id, + action: existing ? "update" : "create", + before: existing ?? undefined, + after: grant, + eventKind: EventKind.INTEGRATION_AUTHORIZATION_CHANGED, + subjectType: "integration-grant", + subjectId: grant.id, + payload: { + connectionAuthorizationId: authorization.id, + principalType: input.principal.type, + scope: input.scope, + projectId, + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return grant; + }), + ), + + revokeGrant: adminProcedure + .input(z.object({ id: z.string().cuid() })) + .mutation(async ({ ctx, input }) => + ctx.db.$transaction(async (tx) => { + const row = await tx.integrationGrant.findFirst({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + }); + if (!row) throw new TRPCError({ code: "NOT_FOUND" }); + if (row.revokedAt) return row; + const updated = await tx.integrationGrant.update({ + where: { id: row.id }, + data: { revokedAt: new Date(), revokedById: ctx.session.user.id }, + }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + entity: "IntegrationGrant", + entityId: row.id, + action: "revoke", + before: row, + after: updated, + eventKind: EventKind.INTEGRATION_AUTHORIZATION_CHANGED, + subjectType: "integration-grant", + subjectId: row.id, + payload: { connectionAuthorizationId: row.connectionAuthorizationId }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return updated; + }), + ), +}); diff --git a/src/server/services/__tests__/github-reconciliation.test.ts b/src/server/services/__tests__/github-reconciliation.test.ts index abf04ddd..0640cef0 100644 --- a/src/server/services/__tests__/github-reconciliation.test.ts +++ b/src/server/services/__tests__/github-reconciliation.test.ts @@ -10,6 +10,7 @@ import { claimGitHubReconciliationCandidate, sweepGitHubStatusReconciliation, } from "@/server/services/github/reconciliation"; +import { ensureMappingAuthorization } from "@/server/services/github/linkability"; import { createIssue, createWorkspaceFixture, @@ -64,6 +65,12 @@ async function setupResource() { target: "acme/forge", }, }); + await ensureMappingAuthorization({ + db: prisma, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + userId: fixture.user.id, + }); const resource = await prisma.externalResource.create({ data: { workspaceId: fixture.workspace.id, @@ -645,7 +652,7 @@ describe("GitHub status reconciliation", () => { }); it("stops starting resources after the workspace sweep budget is exhausted", async () => { - const { fixture, prisma, resource } = await setupResource(); + const { fixture, prisma, resource, mapping } = await setupResource(); const issue = await createIssue(fixture, { statusCategory: "IN_PROGRESS" }); const second = await prisma.externalResource.create({ data: { @@ -657,6 +664,7 @@ describe("GitHub status reconciliation", () => { url: "https://github.com/acme/forge/pull/43", title: "Second PR", state: "open", + connectionMappingId: mapping.id, lastSyncedAt: new Date("2026-07-14T10:00:00.000Z"), }, }); diff --git a/src/server/services/__tests__/github-webhook-hardening.test.ts b/src/server/services/__tests__/github-webhook-hardening.test.ts index 700c71e4..61007cec 100644 --- a/src/server/services/__tests__/github-webhook-hardening.test.ts +++ b/src/server/services/__tests__/github-webhook-hardening.test.ts @@ -16,6 +16,8 @@ import { upsertExternalResource, upsertExternalResourceFromWebhook, } from "@/server/services/github/resource-sync"; +import { ensureMappingAuthorization } from "@/server/services/github/linkability"; +import { connectionAuthorizationDigest } from "@/server/services/integration-authorization"; import { createIssue, createWorkspaceFixture, @@ -78,9 +80,27 @@ async function setup(options: { syncComments?: boolean; autoCreateIssues?: boole : undefined, }, }); + await ensureMappingAuthorization({ + db: prisma, + workspaceId: fixture.workspace.id, + mappingId: mapping.id, + userId: fixture.user.id, + }); return { fixture, prisma, issue, mapping }; } +async function refreshMappingConsent(mappingId: string) { + const prisma = getPrisma(); + const mapping = await prisma.connectionMapping.findUniqueOrThrow({ where: { id: mappingId } }); + await prisma.connectionAuthorization.update({ + where: { connectionMappingId: mapping.id }, + data: { + authorizationDigest: connectionAuthorizationDigest(mapping), + authorizedAt: new Date(), + }, + }); +} + function pullRequest(overrides: Record = {}) { return { id: 4200, @@ -729,6 +749,7 @@ describe("GitHub webhook hardening", () => { config: { github: { statusRules: { checksFailedStatusId: failedStatus.id } } }, }, }); + await refreshMappingConsent(mapping.id); const resource = await upsertExternalResource(prisma, { workspaceId: fixture.workspace.id, connectionMappingId: mapping.id, @@ -809,6 +830,7 @@ describe("GitHub webhook hardening", () => { }, }, }); + await refreshMappingConsent(mapping.id); await processGitHubWebhook({ db: prisma, diff --git a/src/server/services/github/client.ts b/src/server/services/github/client.ts index 570edec5..aa87073c 100644 --- a/src/server/services/github/client.ts +++ b/src/server/services/github/client.ts @@ -206,11 +206,12 @@ async function githubRequest( path: string, init: RequestInit = {}, timeoutMs = DEFAULT_GITHUB_REQUEST_TIMEOUT_MS, + githubAppId?: string | null, ): Promise { // Prefer a configured GithubApp's credentials for this installation, falling // back to the global env app — so linking works off the same app a workspace // set up in Settings → GitHub Apps. - const token = await resolveInstallationToken(installationId); + const token = await resolveInstallationToken(installationId, githubAppId); let res: Response; try { res = await fetch(`${GITHUB_API_BASE}${path}`, { @@ -284,6 +285,7 @@ function assignees(users: GitHubUser[] | undefined): Array<{ login: string }> { export async function getGitHubIssue(args: { installationId: string | number; + githubAppId?: string | null; owner: string; repo: string; number: number; @@ -295,11 +297,13 @@ export async function getGitHubIssue(args: { `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/issues/${args.number}`, { signal: args.signal }, args.requestTimeoutMs, + args.githubAppId, ); } export async function getGitHubPullRequest(args: { installationId: string | number; + githubAppId?: string | null; owner: string; repo: string; number: number; @@ -311,6 +315,7 @@ export async function getGitHubPullRequest(args: { `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/pulls/${args.number}`, { signal: args.signal }, args.requestTimeoutMs, + args.githubAppId, ); } @@ -322,6 +327,7 @@ export async function getGitHubPullRequest(args: { */ export async function getGitHubPullRequestReviewSummary(args: { installationId: string | number; + githubAppId?: string | null; owner: string; repo: string; number: number; @@ -338,6 +344,7 @@ export async function getGitHubPullRequestReviewSummary(args: { `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/pulls/${args.number}/reviews?per_page=100&page=${page}`, { signal: args.signal }, args.requestTimeoutMs, + args.githubAppId, ); reviews.push(...rows); if (rows.length < 100) break; @@ -387,6 +394,7 @@ export async function getGitHubPullRequestReviewSummary(args: { */ export async function getGitHubPullRequestChecks(args: { installationId: string | number; + githubAppId?: string | null; owner: string; repo: string; headSha: string; @@ -403,6 +411,7 @@ export async function getGitHubPullRequestChecks(args: { `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/commits/${ref}/status`, { signal: args.signal }, args.requestTimeoutMs, + args.githubAppId, ), ); const suites: GitHubCheckSuitesResponse & { truncated?: boolean } = @@ -516,6 +525,7 @@ async function settle(fn: () => Promise): Promise> { async function listAllCheckSuites(args: { installationId: string | number; + githubAppId?: string | null; owner: string; repo: string; ref: string; @@ -530,6 +540,7 @@ async function listAllCheckSuites(args: { `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/commits/${args.ref}/check-suites?per_page=100&page=${page}`, { signal: args.signal }, args.requestTimeoutMs, + args.githubAppId, ); const rows = response.check_suites ?? []; check_suites.push(...rows); @@ -543,10 +554,14 @@ async function listAllCheckSuites(args: { export async function listGitHubInstallationRepos(args: { installationId: string | number; + githubAppId?: string | null; }): Promise { const first = await githubRequest( args.installationId, "/installation/repositories?per_page=100", + {}, + DEFAULT_GITHUB_REQUEST_TIMEOUT_MS, + args.githubAppId, ); return first.repositories ?? []; } @@ -559,6 +574,7 @@ export async function getGitHubAppInstallation(args: { export async function searchGitHubIssuesAndPulls(args: { installationId: string | number; + githubAppId?: string | null; repoFullName: string; query: string; type?: "issue" | "pr"; @@ -569,6 +585,9 @@ export async function searchGitHubIssuesAndPulls(args: { const result = await githubRequest<{ items?: GitHubIssueResponse[] }>( args.installationId, `/search/issues?${params.toString()}`, + {}, + DEFAULT_GITHUB_REQUEST_TIMEOUT_MS, + args.githubAppId, ); return result.items ?? []; } diff --git a/src/server/services/github/installation-token.ts b/src/server/services/github/installation-token.ts index d79c2a2b..624a0877 100644 --- a/src/server/services/github/installation-token.ts +++ b/src/server/services/github/installation-token.ts @@ -20,10 +20,13 @@ import { getInstallationAccessToken } from "@/server/services/github/app-auth"; * to the global env app. So configuring one `GithubApp` is enough for both * runtime auth and issue/PR linking — no separate env app required. */ -export async function resolveInstallationToken(installationId: string | number): Promise { +export async function resolveInstallationToken( + installationId: string | number, + githubAppId?: string | null, +): Promise { const key = String(installationId); const app = await db.githubApp.findFirst({ - where: { installationId: key }, + where: githubAppId ? { id: githubAppId, installationId: key } : { installationId: key }, select: { id: true, appId: true, installationId: true, privateKeyEnc: true }, }); if (app?.installationId) { @@ -34,6 +37,9 @@ export async function resolveInstallationToken(installationId: string | number): }); return minted.token; } + if (githubAppId) { + throw new Error("The authorized GitHub App no longer owns this installation."); + } // No GithubApp owns this installation — use the global env app (legacy path). return getInstallationAccessToken(installationId); } diff --git a/src/server/services/github/linkability.ts b/src/server/services/github/linkability.ts index c1b93d08..9f1b5bc3 100644 --- a/src/server/services/github/linkability.ts +++ b/src/server/services/github/linkability.ts @@ -1,11 +1,88 @@ import "server-only"; import { TRPCError } from "@trpc/server"; -import { ConnectionProvider, ConnectionStatus, type Prisma, type PrismaClient } from "@prisma/client"; +import { + ConnectionProvider, + ConnectionStatus, + IntegrationCapability, + IntegrationCredentialSource, + IntegrationGrantScope, + IntegrationPrincipalType, + type Prisma, + type PrismaClient, +} from "@prisma/client"; import { decryptSecret } from "@/server/crypto"; -import { listGitHubInstallationRepos, type GitHubRepoResponse } from "@/server/services/github/client"; +import { + listGitHubInstallationRepos, + type GitHubRepoResponse, +} from "@/server/services/github/client"; import { getInstallationAccountLogin } from "@/server/services/github-app"; import { githubInstallationId } from "@/server/services/github/mapping-policy"; import { normalizeRepoFullName, sameRepo } from "@/server/services/github/url"; +import { connectionAuthorizationDigest } from "@/server/services/integration-authorization"; + +const DEFAULT_MAPPING_CAPABILITIES = Object.values(IntegrationCapability); + +export async function ensureMappingAuthorization(args: { + db: PrismaClient; + workspaceId: string; + mappingId: string; + userId: string; + githubAppId?: string | null; +}): Promise { + const mapping = await args.db.connectionMapping.findFirst({ + where: { id: args.mappingId, workspaceId: args.workspaceId }, + include: { authorization: true }, + }); + if (!mapping || mapping.authorization) return; + const membership = await args.db.membership.findUnique({ + where: { userId_workspaceId: { userId: args.userId, workspaceId: args.workspaceId } }, + select: { id: true }, + }); + if (!membership) + throw new TRPCError({ code: "FORBIDDEN", message: "Workspace membership required." }); + const authorization = await args.db.connectionAuthorization.create({ + data: { + workspaceId: args.workspaceId, + connectionMappingId: mapping.id, + credentialSource: args.githubAppId + ? IntegrationCredentialSource.WORKSPACE_GITHUB_APP + : IntegrationCredentialSource.USER_CONNECTION, + githubAppId: args.githubAppId ?? null, + capabilities: DEFAULT_MAPPING_CAPABILITIES, + authorizedById: args.userId, + authorizationDigest: connectionAuthorizationDigest(mapping), + authorizedAt: new Date(), + }, + }); + const grants: Prisma.IntegrationGrantCreateManyInput[] = [ + { + workspaceId: args.workspaceId, + connectionAuthorizationId: authorization.id, + principalType: IntegrationPrincipalType.USER, + principalUserId: args.userId, + scope: IntegrationGrantScope.WORKSPACE, + capabilities: DEFAULT_MAPPING_CAPABILITIES, + grantedById: args.userId, + }, + ]; + if (mapping.direction === "inbound" || mapping.direction === "inbound+outbound") { + grants.push({ + workspaceId: args.workspaceId, + connectionAuthorizationId: authorization.id, + principalType: IntegrationPrincipalType.WORKSPACE_AUTOMATION, + principalUserId: null, + scope: IntegrationGrantScope.WORKSPACE, + capabilities: [ + IntegrationCapability.READ, + IntegrationCapability.IMPORT, + IntegrationCapability.LINK, + IntegrationCapability.SYNC, + ], + grantedById: args.userId, + }); + } + await args.db.integrationGrant.createMany({ data: grants }); +} /** * "Can this workspace link a PR/issue from `owner/repo` right now, and if @@ -96,14 +173,22 @@ export function classifyLinkability(args: { return { status: "mappable", repoFullName, - connections: withRepo.map(({ connectionId, account, label }) => ({ connectionId, account, label })), + connections: withRepo.map(({ connectionId, account, label }) => ({ + connectionId, + account, + label, + })), }; } if (args.connections.length > 0) { return { status: "needs_repo_access", repoFullName, - connections: args.connections.map(({ connectionId, account, label }) => ({ connectionId, account, label })), + connections: args.connections.map(({ connectionId, account, label }) => ({ + connectionId, + account, + label, + })), }; } return { status: "no_connection", repoFullName }; @@ -112,10 +197,19 @@ export function classifyLinkability(args: { /** How many connections we'll probe against the GitHub API per resolve. */ const MAX_PROBE_CONNECTIONS = 8; -type RepoLister = (args: { installationId: string | number }) => Promise; +type RepoLister = (args: { + installationId: string | number; + githubAppId?: string | null; +}) => Promise; /** A CONNECTED GitHub connection the workspace can map repos from. */ -type CandidateConnection = { id: string; account: string | null; label: string; config: unknown }; +type CandidateConnection = { + id: string; + account: string | null; + label: string; + config: unknown; + githubAppId: string | null; +}; /** * The CONNECTED GitHub connections this workspace can act on: every connection @@ -133,6 +227,7 @@ async function gatherCandidateGitHubConnections( const repoMappings = await db.connectionMapping.findMany({ where: { workspaceId, kind: "repo", connection: { provider: ConnectionProvider.GITHUB } }, select: { + authorization: { select: { githubAppId: true } }, connection: { select: { id: true, account: true, label: true, status: true, config: true } }, }, }); @@ -142,6 +237,7 @@ async function gatherCandidateGitHubConnections( account: m.connection.account, label: m.connection.label, config: m.connection.config, + githubAppId: m.authorization?.githubAppId ?? null, }); } if (userId) { @@ -154,7 +250,14 @@ async function gatherCandidateGitHubConnections( select: { id: true, account: true, label: true, config: true }, }); for (const c of owned) { - if (!byId.has(c.id)) byId.set(c.id, { account: c.account, label: c.label, config: c.config }); + if (!byId.has(c.id)) { + byId.set(c.id, { + account: c.account, + label: c.label, + config: c.config, + githubAppId: null, + }); + } } } return [...byId.entries()].map(([id, c]) => ({ id, ...c })); @@ -209,7 +312,11 @@ export async function resolveRepoLinkability(args: { // Fast path: an active or paused mapping already covers the repo. This is // safe for any member — repo mappings are already visible via // `connectionMapping.list` — and makes no GitHub API call. - const decidedByMapping = classifyLinkability({ repoFullName, mappings: mappingViews, connections: [] }); + const decidedByMapping = classifyLinkability({ + repoFullName, + mappings: mappingViews, + connections: [], + }); if (decidedByMapping.status === "ready" || decidedByMapping.status === "paused") { return decidedByMapping; } @@ -232,7 +339,7 @@ export async function resolveRepoLinkability(args: { let hasRepo = false; try { const installationId = githubInstallationId({ config: c.config as never }); - const repos = await listRepos({ installationId }); + const repos = await listRepos({ installationId, githubAppId: c.githubAppId }); hasRepo = repoInInstallation(repos, repoFullName); } catch (err) { // Unreachable installation (revoked, missing id, API error) — still a @@ -249,7 +356,11 @@ export async function resolveRepoLinkability(args: { }), ); - const decided = classifyLinkability({ repoFullName, mappings: mappingViews, connections: considered }); + const decided = classifyLinkability({ + repoFullName, + mappings: mappingViews, + connections: considered, + }); // No GitHub Connection at all, but the workspace already has an installed // GithubApp? Offer the one-click "use this app for linking" path instead of @@ -293,6 +404,7 @@ export async function mapGitHubRepo(args: { direction?: string; labelIds?: string[]; listRepos?: RepoLister; + githubAppId?: string | null; }): Promise<{ id: string; target: string; reactivated: boolean }> { const repoFullName = normalizeRepoFullName(args.repoFullName); const listRepos = args.listRepos ?? listGitHubInstallationRepos; @@ -331,7 +443,7 @@ export async function mapGitHubRepo(args: { } let repos: GitHubRepoResponse[]; try { - repos = await listRepos({ installationId }); + repos = await listRepos({ installationId, githubAppId: args.githubAppId }); } catch (err) { // A transient GitHub failure shouldn't surface as an opaque 500 — make it // a retryable upstream error. @@ -359,12 +471,28 @@ export async function mapGitHubRepo(args: { }); const match = repoMappings.find((m) => sameRepo(m.target, repoFullName)) ?? null; if (match) { - if (match.status === "active") return { id: match.id, target: match.target, reactivated: false }; + if (match.status === "active") { + await ensureMappingAuthorization({ + db: args.db, + workspaceId: args.workspaceId, + mappingId: match.id, + userId: args.userId, + githubAppId: args.githubAppId, + }); + return { id: match.id, target: match.target, reactivated: false }; + } const updated = await args.db.connectionMapping.update({ where: { id: match.id }, data: { status: "active" }, select: { id: true, target: true }, }); + await ensureMappingAuthorization({ + db: args.db, + workspaceId: args.workspaceId, + mappingId: updated.id, + userId: args.userId, + githubAppId: args.githubAppId, + }); return { id: updated.id, target: updated.target, reactivated: true }; } @@ -380,6 +508,13 @@ export async function mapGitHubRepo(args: { }, select: { id: true, target: true }, }); + await ensureMappingAuthorization({ + db: args.db, + workspaceId: args.workspaceId, + mappingId: created.id, + userId: args.userId, + githubAppId: args.githubAppId, + }); return { id: created.id, target: created.target, reactivated: false }; } @@ -410,7 +545,8 @@ export async function connectGithubAppAsConnection(args: { if (!app?.installationId) { throw new TRPCError({ code: "NOT_FOUND", - message: "No installed GitHub App found in this workspace. Install one in Settings → GitHub Apps.", + message: + "No installed GitHub App found in this workspace. Install one in Settings → GitHub Apps.", }); } @@ -475,6 +611,7 @@ export async function connectGithubAppAsConnection(args: { connectionId, repoFullName: args.repoFullName, listRepos: args.listRepos, + githubAppId: app.id, }); mapped = true; } catch { @@ -650,15 +787,13 @@ export async function ensureGitHubRepoLinkable(args: { } // 2. A connected GitHub connection whose installation already includes the repo. - const candidates = await gatherCandidateGitHubConnections( - args.db, - args.workspaceId, - args.userId, - ); + const candidates = await gatherCandidateGitHubConnections(args.db, args.workspaceId, args.userId); for (const c of candidates.slice(0, MAX_PROBE_CONNECTIONS)) { let reachable = false; try { - const repos = await listRepos({ installationId: githubInstallationId({ config: c.config as never }) }); + const repos = await listRepos({ + installationId: githubInstallationId({ config: c.config as never }), + }); reachable = repoInInstallation(repos, repoFullName); } catch { reachable = false; diff --git a/src/server/services/github/reconciliation.ts b/src/server/services/github/reconciliation.ts index 99904941..fb4426ee 100644 --- a/src/server/services/github/reconciliation.ts +++ b/src/server/services/github/reconciliation.ts @@ -1,9 +1,16 @@ import "server-only"; -import type { PrismaClient } from "@prisma/client"; +import { + ConnectionStatus, + IntegrationCapability, + IntegrationGrantScope, + IntegrationPrincipalType, + type PrismaClient, +} from "@prisma/client"; import { logger } from "@/server/logger"; import { GitHubRequestError } from "@/server/services/github/client"; import { syncGitHubExternalResource } from "@/server/services/github/resource-sync"; import { IMPLEMENTATION_LINK_KINDS } from "@/server/services/github/types"; +import { assertIntegrationAction } from "@/server/services/integration-authorization"; const PROVIDER = "GITHUB"; const RESOURCE_TYPE = "PULL_REQUEST"; @@ -173,6 +180,31 @@ export async function sweepGitHubStatusReconciliation( workspaceId: workspace.id, provider: PROVIDER, resourceType: RESOURCE_TYPE, + connectionMapping: { + is: { + status: "active", + connection: { status: ConnectionStatus.CONNECTED }, + authorization: { + is: { + revokedAt: null, + capabilities: { + hasEvery: [IntegrationCapability.READ, IntegrationCapability.SYNC], + }, + grants: { + some: { + principalType: IntegrationPrincipalType.WORKSPACE_AUTOMATION, + scope: IntegrationGrantScope.WORKSPACE, + projectId: null, + revokedAt: null, + capabilities: { + hasEvery: [IntegrationCapability.READ, IntegrationCapability.SYNC], + }, + }, + }, + }, + }, + }, + }, OR: [ { syncTerminalAt: null, @@ -230,6 +262,18 @@ export async function sweepGitHubStatusReconciliation( } try { + if (!candidate.connectionMappingId) { + result.skipped += 1; + continue; + } + await assertIntegrationAction({ + db, + workspaceId: workspace.id, + mappingId: candidate.connectionMappingId, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "SYNC", + projectId: null, + }); const resource = await syncResource({ db, workspaceId: workspace.id, diff --git a/src/server/services/github/resource-sync.ts b/src/server/services/github/resource-sync.ts index c9121a92..4f69ad07 100644 --- a/src/server/services/github/resource-sync.ts +++ b/src/server/services/github/resource-sync.ts @@ -47,6 +47,7 @@ type DbClient = PrismaClient | Prisma.TransactionClient; type GitHubMappingWithConnection = ConnectionMapping & { connection: { id: string; provider: ConnectionProvider; config: Prisma.JsonValue | null }; + authorization: { githubAppId: string | null } | null; }; export type ActorMeta = { @@ -341,7 +342,10 @@ export async function resolveGitHubRepoMapping(args: { ...(requireActive ? { status: "active" } : {}), connection: { provider: ConnectionProvider.GITHUB }, }, - include: { connection: { select: { id: true, provider: true, config: true } } }, + include: { + connection: { select: { id: true, provider: true, config: true } }, + authorization: { select: { githubAppId: true } }, + }, }); if (!mapping) { throw new TRPCError({ code: "NOT_FOUND", message: "GitHub repo mapping not found." }); @@ -369,7 +373,10 @@ export async function resolveGitHubRepoMapping(args: { ...(requireActive ? { status: "active" } : {}), connection: { provider: ConnectionProvider.GITHUB }, }, - include: { connection: { select: { id: true, provider: true, config: true } } }, + include: { + connection: { select: { id: true, provider: true, config: true } }, + authorization: { select: { githubAppId: true } }, + }, }); const mapping = mappings.find((m) => sameRepo(m.target, repoFullName)); if (!mapping) { @@ -389,11 +396,13 @@ async function enrichPullRequestSnapshot(args: { repo: string; requestTimeoutMs?: number; signal?: AbortSignal; + githubAppId?: string | null; }): Promise { const metadata = metadataRecord(args.snapshot.metadata); try { const review = await getGitHubPullRequestReviewSummary({ installationId: args.installationId, + githubAppId: args.githubAppId, owner: args.owner, repo: args.repo, number: args.pullRequest.number, @@ -423,6 +432,7 @@ async function enrichPullRequestSnapshot(args: { ...metadataRecord(args.snapshot.metadata), checks: await getGitHubPullRequestChecks({ installationId: args.installationId, + githubAppId: args.githubAppId, owner: args.owner, repo: args.repo, headSha: args.pullRequest.head.sha, @@ -440,9 +450,11 @@ export async function fetchGitHubSnapshotForParsed( options: { requestTimeoutMs?: number; signal?: AbortSignal } = {}, ): Promise { const installationId = githubInstallationId(mapping.connection); + const githubAppId = mapping.authorization?.githubAppId ?? null; if (parsed.type === "PULL_REQUEST") { const pr = await getGitHubPullRequest({ installationId, + githubAppId, owner: parsed.owner, repo: parsed.repo, number: parsed.number, @@ -456,6 +468,7 @@ export async function fetchGitHubSnapshotForParsed( snapshot, pullRequest: pr, installationId, + githubAppId, owner: parsed.owner, repo: parsed.repo, requestTimeoutMs: options.requestTimeoutMs, @@ -464,6 +477,7 @@ export async function fetchGitHubSnapshotForParsed( } const issue = await getGitHubIssue({ installationId, + githubAppId, owner: parsed.owner, repo: parsed.repo, number: parsed.number, @@ -473,6 +487,7 @@ export async function fetchGitHubSnapshotForParsed( if (issue.pull_request) { const pr = await getGitHubPullRequest({ installationId, + githubAppId, owner: parsed.owner, repo: parsed.repo, number: parsed.number, @@ -484,6 +499,7 @@ export async function fetchGitHubSnapshotForParsed( snapshot, pullRequest: pr, installationId, + githubAppId, owner: parsed.owner, repo: parsed.repo, requestTimeoutMs: options.requestTimeoutMs, @@ -1385,7 +1401,10 @@ export async function syncGitHubExternalResource(args: { }, include: { connectionMapping: { - include: { connection: { select: { id: true, provider: true, config: true } } }, + include: { + connection: { select: { id: true, provider: true, config: true } }, + authorization: { select: { githubAppId: true } }, + }, }, }, }); diff --git a/src/server/services/github/webhook.ts b/src/server/services/github/webhook.ts index 0c3e95fe..ab0b8914 100644 --- a/src/server/services/github/webhook.ts +++ b/src/server/services/github/webhook.ts @@ -1,6 +1,7 @@ import "server-only"; import { createHmac, timingSafeEqual } from "node:crypto"; import { CommentKind, type ExternalResource, type Prisma, type PrismaClient } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; import { recordChange } from "@/server/audit"; import { decryptSecret } from "@/server/crypto"; import { createIssueWithSideEffects } from "@/server/services/issue-create"; @@ -34,6 +35,7 @@ import { type GitHubResourceSnapshot, } from "@/server/services/github/types"; import { derivePullRequestIssueRelations } from "@/server/services/github/relation"; +import { assertIntegrationAction } from "@/server/services/integration-authorization"; type GitHubWebhookRepository = { full_name?: string; @@ -1047,6 +1049,49 @@ export async function processGitHubWebhook(args: { let processed = 0; try { for (const mapping of mappings) { + try { + await assertIntegrationAction({ + db: args.db, + workspaceId: mapping.workspaceId, + mappingId: mapping.id, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "SYNC", + projectId: null, + }); + const config = readGitHubMappingConfig(mapping.config); + if ( + args.event === "issues" && + args.payload.action === "opened" && + config.autoCreateIssues + ) { + await assertIntegrationAction({ + db: args.db, + workspaceId: mapping.workspaceId, + mappingId: mapping.id, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "IMPORT", + projectId: config.defaultProjectId ?? null, + }); + } + if (args.event === "pull_request") { + await assertIntegrationAction({ + db: args.db, + workspaceId: mapping.workspaceId, + mappingId: mapping.id, + principal: { type: "WORKSPACE_AUTOMATION" }, + action: "LINK", + projectId: null, + }); + } + } catch (error) { + if ( + error instanceof TRPCError && + ["FORBIDDEN", "PRECONDITION_FAILED", "NOT_FOUND"].includes(error.code) + ) { + continue; + } + throw error; + } if (args.event === "issues") { processed += await processIssueEvent({ db: args.db, diff --git a/src/server/services/integration-authorization.ts b/src/server/services/integration-authorization.ts new file mode 100644 index 00000000..4be4928a --- /dev/null +++ b/src/server/services/integration-authorization.ts @@ -0,0 +1,389 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { + ConnectionProvider, + ConnectionStatus, + IntegrationCapability, + IntegrationCredentialSource, + IntegrationGrantScope, + IntegrationPrincipalType, + type PrismaClient, +} from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { canPerformProjectAction, type ProjectAction } from "@/server/services/authorization"; +import { + githubInstallationId, + readGitHubConnectionConfig, +} from "@/server/services/github/mapping-policy"; +import { readGithubAppSyncReadiness } from "@/server/services/github-app"; +import { decryptSecret } from "@/server/crypto"; + +export type IntegrationAction = IntegrationCapability; + +export type IntegrationPrincipal = + | { type: "USER"; userId: string } + | { type: "AGENT"; agentId: string; apiKeyId: string } + | { type: "API_KEY"; apiKeyId: string } + | { type: "WORKSPACE_AUTOMATION" }; + +const ACTION_CAPABILITIES: Record = { + READ: [IntegrationCapability.READ], + LINK: [IntegrationCapability.READ, IntegrationCapability.LINK], + IMPORT: [IntegrationCapability.READ, IntegrationCapability.IMPORT], + SYNC: [IntegrationCapability.READ, IntegrationCapability.SYNC], + WRITE: [IntegrationCapability.READ, IntegrationCapability.WRITE], + ADMIN: [IntegrationCapability.ADMIN], +}; + +export function integrationRequiredCapabilities( + action: IntegrationAction, +): readonly IntegrationCapability[] { + return ACTION_CAPABILITIES[action]; +} + +const ACTION_PROJECT_AUTHORITY: Record = { + READ: "READ", + LINK: "CONTRIBUTE", + IMPORT: "CONTRIBUTE", + SYNC: "CONTRIBUTE", + WRITE: "CONTRIBUTE", + ADMIN: "MANAGE", +}; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stableValue(item)]), + ); +} + +/** + * Digest only fields that can widen a mapping's authority. Cosmetic timestamps + * and health diagnostics deliberately do not invalidate consent. + */ +export function connectionAuthorizationDigest(mapping: { + connectionId: string; + kind: string; + target: string; + direction: string; + labelIds: string[]; + routeTo: string | null; + config: unknown; +}): string { + const policy = stableValue({ + connectionId: mapping.connectionId, + kind: mapping.kind, + target: mapping.target.trim().toLowerCase(), + direction: mapping.direction, + labelIds: [...mapping.labelIds].sort(), + routeTo: mapping.routeTo, + config: mapping.config, + }); + return createHash("sha256").update(JSON.stringify(policy)).digest("hex"); +} + +function includesAll( + actual: readonly IntegrationCapability[], + required: readonly IntegrationCapability[], +): boolean { + const set = new Set(actual); + return required.every((capability) => set.has(capability)); +} + +export function integrationDirectionAllows(direction: string, action: IntegrationAction): boolean { + if (action === IntegrationCapability.ADMIN) return true; + const required = action === IntegrationCapability.WRITE ? "outbound" : "inbound"; + return direction === required || direction === "inbound+outbound"; +} + +function principalWhere(principal: IntegrationPrincipal) { + switch (principal.type) { + case "USER": + return { + principalType: IntegrationPrincipalType.USER, + principalUserId: principal.userId, + } as const; + case "AGENT": + return { + principalType: IntegrationPrincipalType.AGENT, + principalAgentId: principal.agentId, + } as const; + case "API_KEY": + return { + principalType: IntegrationPrincipalType.API_KEY, + principalApiKeyId: principal.apiKeyId, + } as const; + case "WORKSPACE_AUTOMATION": + return { principalType: IntegrationPrincipalType.WORKSPACE_AUTOMATION } as const; + } +} + +async function assertHumanProjectAuthority(args: { + db: PrismaClient; + workspaceId: string; + userId: string; + projectId: string | null; + action: IntegrationAction; +}): Promise { + const membership = await args.db.membership.findUnique({ + where: { userId_workspaceId: { userId: args.userId, workspaceId: args.workspaceId } }, + select: { id: true, role: true }, + }); + if (!membership) + throw new TRPCError({ code: "FORBIDDEN", message: "Workspace access required." }); + if (!args.projectId) return; + const project = await args.db.project.findFirst({ + where: { id: args.projectId, workspaceId: args.workspaceId, deletedAt: null }, + select: { + visibility: true, + accessGrants: { + where: { membershipId: membership.id }, + select: { role: true }, + take: 1, + }, + }, + }); + if (!project) throw new TRPCError({ code: "NOT_FOUND", message: "Project not found." }); + if ( + !canPerformProjectAction({ + membershipRole: membership.role, + visibility: project.visibility, + accessRole: project.accessGrants[0]?.role ?? null, + action: ACTION_PROJECT_AUTHORITY[args.action], + }) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project access does not permit this integration action.", + }); + } +} + +async function assertExactGithubAuthority(args: { + db: PrismaClient; + workspaceId: string; + authorization: { + credentialSource: IntegrationCredentialSource; + githubAppId: string | null; + authorizedById: string; + }; + connection: { ownerId: string; provider: ConnectionProvider; config: unknown }; +}): Promise { + if (args.connection.provider !== ConnectionProvider.GITHUB) return; + if (args.authorization.credentialSource === IntegrationCredentialSource.USER_CONNECTION) { + if (args.authorization.authorizedById !== args.connection.ownerId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The credential owner has not authorized this mapping.", + }); + } + return; + } + if (!args.authorization.githubAppId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Workspace GitHub App authorization is incomplete.", + }); + } + const app = await args.db.githubApp.findFirst({ + where: { id: args.authorization.githubAppId, workspaceId: args.workspaceId }, + select: { installationId: true }, + }); + const installationId = githubInstallationId(args.connection as never); + if (!app?.installationId || app.installationId !== installationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The mapping is not bound to its authorized GitHub App installation.", + }); + } +} + +export type IntegrationAuthorization = { + mappingId: string; + connectionAuthorizationId: string; + grantId: string; + projectId: string | null; + capabilities: IntegrationCapability[]; +}; + +/** Central deny-by-default integration authorization boundary. */ +export async function assertIntegrationAction(args: { + db: PrismaClient; + workspaceId: string; + mappingId: string; + principal: IntegrationPrincipal; + action: IntegrationAction; + projectId?: string | null; +}): Promise { + const required = ACTION_CAPABILITIES[args.action]; + const mapping = await args.db.connectionMapping.findFirst({ + where: { id: args.mappingId, workspaceId: args.workspaceId }, + include: { + connection: { + select: { + id: true, + ownerId: true, + provider: true, + status: true, + config: true, + scopes: true, + }, + }, + authorization: true, + }, + }); + if (!mapping) + throw new TRPCError({ code: "NOT_FOUND", message: "Integration mapping not found." }); + if (mapping.status !== "active" || mapping.connection.status !== ConnectionStatus.CONNECTED) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Integration mapping is not active and connected.", + }); + } + if (!integrationDirectionAllows(mapping.direction, args.action)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Mapping direction does not permit this action.", + }); + } + const authorization = mapping.authorization; + const digestMatches = authorization + ? authorization.authorizationDigest === connectionAuthorizationDigest(mapping) || + (authorization.authorizationDigest === `legacy:${mapping.id}` && + authorization.authorizedAt >= mapping.updatedAt) + : false; + if ( + !authorization || + authorization.revokedAt || + !digestMatches || + !includesAll(authorization.capabilities, required) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Credential-owner authorization is missing, stale, or insufficient.", + }); + } + await assertExactGithubAuthority({ + db: args.db, + workspaceId: args.workspaceId, + authorization, + connection: mapping.connection, + }); + + const projectId = args.projectId ?? null; + const grants = await args.db.integrationGrant.findMany({ + where: { + workspaceId: args.workspaceId, + connectionAuthorizationId: authorization.id, + revokedAt: null, + ...principalWhere(args.principal), + OR: [ + { scope: IntegrationGrantScope.WORKSPACE, projectId: null }, + ...(projectId ? [{ scope: IntegrationGrantScope.PROJECT, projectId }] : []), + ], + }, + select: { id: true, capabilities: true }, + }); + const grant = grants.find((candidate) => includesAll(candidate.capabilities, required)); + if (!grant) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "An explicit integration grant is required for this action.", + }); + } + + if (args.principal.type === "USER") { + await assertHumanProjectAuthority({ + db: args.db, + workspaceId: args.workspaceId, + userId: args.principal.userId, + projectId, + action: args.action, + }); + } else if (args.principal.type === "AGENT" || args.principal.type === "API_KEY") { + const keyId = args.principal.apiKeyId; + const key = await args.db.apiKey.findFirst({ + where: { + id: keyId, + workspaceId: args.workspaceId, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + select: { id: true, projectIds: true }, + }); + if (!key || (projectId && key.projectIds.length > 0 && !key.projectIds.includes(projectId))) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "API key scope does not permit this integration action.", + }); + } + } + + return { + mappingId: mapping.id, + connectionAuthorizationId: authorization.id, + grantId: grant.id, + projectId, + capabilities: [...required], + }; +} + +/** Resolve an exact principal; never use a fallback author as authority. */ +export function integrationPrincipalFromContext(ctx: { + userId: string | null; + apiKey?: { keyId: string; linkedAgentId: string | null } | null; +}): IntegrationPrincipal { + if (ctx.apiKey?.linkedAgentId) { + return { type: "AGENT", agentId: ctx.apiKey.linkedAgentId, apiKeyId: ctx.apiKey.keyId }; + } + if (ctx.apiKey) return { type: "API_KEY", apiKeyId: ctx.apiKey.keyId }; + if (ctx.userId) return { type: "USER", userId: ctx.userId }; + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "An exact integration principal is required.", + }); +} + +/** Read current GitHub installation permissions from the exact authorized app. */ +export async function assertGitHubProviderCapabilities(args: { + db: PrismaClient; + workspaceId: string; + mappingId: string; +}): Promise { + const row = await args.db.connectionMapping.findFirst({ + where: { id: args.mappingId, workspaceId: args.workspaceId }, + include: { authorization: true, connection: { select: { config: true } } }, + }); + const appId = row?.authorization?.githubAppId; + if (!row || !appId) return; // personal OAuth/App fallback is enforced by the provider request itself + const app = await args.db.githubApp.findFirst({ + where: { id: appId, workspaceId: args.workspaceId }, + select: { appId: true, installationId: true, privateKeyEnc: true }, + }); + if (!app?.installationId) + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Authorized GitHub App is not installed.", + }); + const readiness = await readGithubAppSyncReadiness({ + appId: app.appId, + installationId: app.installationId, + privateKeyPem: decryptSecret(app.privateKeyEnc), + }); + if (readiness.missingInstallationPermissions.length > 0) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `GitHub installation lacks required permissions: ${readiness.missingInstallationPermissions.join(", ")}.`, + }); + } + const configuredInstallationId = readGitHubConnectionConfig(row.connection.config).installationId; + if (String(configuredInstallationId ?? "") !== app.installationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "GitHub App installation binding is inconsistent.", + }); + } +} diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index c7f2745d..603fddce 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -147,8 +147,15 @@ import { } from "@/server/services/github/resource-sync"; import { searchGitHubIssuesAndPulls } from "@/server/services/github/client"; import { listGitHubRepoMappings } from "@/server/services/github/linkability"; -import { githubInstallationId } from "@/server/services/github/mapping-policy"; +import { + githubInstallationId, + readGitHubMappingConfig, +} from "@/server/services/github/mapping-policy"; import { parseGitHubUrl } from "@/server/services/github/url"; +import { + assertIntegrationAction, + integrationPrincipalFromContext, +} from "@/server/services/integration-authorization"; import { parseGitHubIssueOrPrRef } from "@/lib/github-ref"; import { EXTERNAL_LINK_KINDS, @@ -8760,6 +8767,26 @@ export const mcpTools = { ctx: McpContext, ) { await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: input.issueId }); + const issue = await db.issue.findFirst({ + where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { projectId: true }, + }); + if (!issue) throw new Error("Issue not found."); + const parsed = parseGitHubUrl(input.url); + const mapping = await resolveGitHubRepoMapping({ + db, + workspaceId: ctx.workspaceId, + mappingId: input.mappingId, + repoFullName: parsed.repoFullName, + }); + await assertIntegrationAction({ + db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: integrationPrincipalFromContext(ctx), + action: "LINK", + projectId: issue.projectId, + }); const actorId = await resolveActorId(ctx); return linkGitHubUrlToIssue({ db, @@ -8804,6 +8831,23 @@ export const mcpTools = { if (input.projectId) { await assertKeyScope(scopeCtx(ctx), { entity: "project", id: input.projectId }); } + const parsed = input.url ? parseGitHubUrl(input.url) : null; + const mapping = await resolveGitHubRepoMapping({ + db, + workspaceId: ctx.workspaceId, + mappingId: input.mappingId, + repoFullName: parsed?.repoFullName ?? input.repoFullName, + }); + const projectId = + input.projectId ?? readGitHubMappingConfig(mapping.config).defaultProjectId ?? null; + await assertIntegrationAction({ + db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: integrationPrincipalFromContext(ctx), + action: "IMPORT", + projectId, + }); const actorId = await resolveActorId(ctx); const result = await importGitHubIssue({ db, @@ -8813,7 +8857,7 @@ export const mcpTools = { repoFullName: input.repoFullName, resourceType: input.resourceType, number: input.number, - projectId: input.projectId, + projectId, labelIds: input.labelIds, queue: input.queue, actor: { @@ -8837,11 +8881,34 @@ export const mcpTools = { workspaceId: ctx.workspaceId, externalResourceId: input.externalResourceId, }, - select: { issueId: true }, + select: { issueId: true, issue: { select: { projectId: true } } }, }); for (const link of links) { await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: link.issueId }); } + const resource = await db.externalResource.findFirst({ + where: { + id: input.externalResourceId, + workspaceId: ctx.workspaceId, + provider: "GITHUB", + }, + select: { connectionMappingId: true }, + }); + if (!resource?.connectionMappingId) { + throw new Error("GitHub resource has no active credential mapping."); + } + const projectIds = new Set(links.map((link) => link.issue.projectId)); + if (projectIds.size === 0) projectIds.add(null); + for (const projectId of projectIds) { + await assertIntegrationAction({ + db, + workspaceId: ctx.workspaceId, + mappingId: resource.connectionMappingId, + principal: integrationPrincipalFromContext(ctx), + action: "SYNC", + projectId, + }); + } const actorId = await resolveActorId(ctx); return syncGitHubExternalResource({ db, @@ -8858,18 +8925,40 @@ export const mcpTools = { "github.listMappings": { scopes: ["READ_ISSUES"] as const, input: z - .object({ includePaused: z.boolean().default(false) }) + .object({ + includePaused: z.boolean().default(false), + projectId: z.string().cuid().nullable().optional(), + }) .default({ includePaused: false }), - async run(input: { includePaused: boolean }, ctx: McpContext) { + async run(input: { includePaused: boolean; projectId?: string | null }, ctx: McpContext) { // Active repo mappings the agent can link/search against. Pair with // `github.search` (needs a mappingId) and `github.link` (resolves a // mapping by repo from the URL) so agents can discover which repos are // wired up before acting. - return listGitHubRepoMappings({ + const mappings = await listGitHubRepoMappings({ db, workspaceId: ctx.workspaceId, includePaused: input.includePaused, }); + const authorized = []; + for (const mapping of mappings) { + try { + await assertIntegrationAction({ + db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: integrationPrincipalFromContext(ctx), + action: "READ", + projectId: input.projectId ?? null, + }); + authorized.push(mapping); + } catch (error) { + if (error instanceof TRPCError && error.code === "FORBIDDEN") continue; + if (error instanceof TRPCError && error.code === "PRECONDITION_FAILED") continue; + throw error; + } + } + return authorized; }, }, @@ -8879,15 +8968,28 @@ export const mcpTools = { mappingId: z.string().cuid(), query: z.string().min(1).max(200), type: z.enum(["issue", "pr"]).optional(), + projectId: z.string().cuid().nullable().optional(), }), - async run(input: { mappingId: string; query: string; type?: "issue" | "pr" }, ctx: McpContext) { + async run( + input: { mappingId: string; query: string; type?: "issue" | "pr"; projectId?: string | null }, + ctx: McpContext, + ) { const mapping = await resolveGitHubRepoMapping({ db, workspaceId: ctx.workspaceId, mappingId: input.mappingId, }); + await assertIntegrationAction({ + db, + workspaceId: ctx.workspaceId, + mappingId: mapping.id, + principal: integrationPrincipalFromContext(ctx), + action: "READ", + projectId: input.projectId ?? null, + }); return searchGitHubIssuesAndPulls({ installationId: githubInstallationId(mapping.connection), + githubAppId: mapping.authorization?.githubAppId ?? null, repoFullName: mapping.target, query: input.query, type: input.type, diff --git a/tests/unit/integration-authorization.test.ts b/tests/unit/integration-authorization.test.ts new file mode 100644 index 00000000..81ba17a4 --- /dev/null +++ b/tests/unit/integration-authorization.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { IntegrationCapability } from "@prisma/client"; +import { + connectionAuthorizationDigest, + integrationDirectionAllows, + integrationPrincipalFromContext, + integrationRequiredCapabilities, +} from "@/server/services/integration-authorization"; + +describe("integration authorization primitives", () => { + it("requires exact capabilities instead of treating admin as a data bypass", () => { + expect(integrationRequiredCapabilities(IntegrationCapability.LINK)).toEqual([ + IntegrationCapability.READ, + IntegrationCapability.LINK, + ]); + expect(integrationRequiredCapabilities(IntegrationCapability.SYNC)).toEqual([ + IntegrationCapability.READ, + IntegrationCapability.SYNC, + ]); + expect(integrationRequiredCapabilities(IntegrationCapability.ADMIN)).toEqual([ + IntegrationCapability.ADMIN, + ]); + }); + + it("keeps inbound provider reads separate from outbound writes", () => { + expect(integrationDirectionAllows("inbound", IntegrationCapability.READ)).toBe(true); + expect(integrationDirectionAllows("outbound", IntegrationCapability.READ)).toBe(false); + expect(integrationDirectionAllows("inbound", IntegrationCapability.WRITE)).toBe(false); + expect(integrationDirectionAllows("outbound", IntegrationCapability.WRITE)).toBe(true); + expect(integrationDirectionAllows("inbound+outbound", IntegrationCapability.SYNC)).toBe(true); + }); + + it("digests security fields deterministically and invalidates widened policy", () => { + const mapping = { + connectionId: "connection-1", + kind: "repo", + target: "Codename-11/Forge", + direction: "inbound+outbound", + labelIds: ["label-b", "label-a"], + routeTo: "Issue", + config: { github: { syncTitle: true, filters: { z: 1, a: 2 } } }, + }; + const reordered = { + ...mapping, + target: "codename-11/forge", + labelIds: ["label-a", "label-b"], + config: { github: { filters: { a: 2, z: 1 }, syncTitle: true } }, + }; + expect(connectionAuthorizationDigest(mapping)).toBe(connectionAuthorizationDigest(reordered)); + expect(connectionAuthorizationDigest({ ...mapping, direction: "outbound" })).not.toBe( + connectionAuthorizationDigest(mapping), + ); + }); + + it("resolves the exact MCP principal without falling back to an arbitrary user", () => { + expect( + integrationPrincipalFromContext({ + userId: "issuer-user", + apiKey: { keyId: "key-1", linkedAgentId: "agent-1" }, + }), + ).toEqual({ type: "AGENT", agentId: "agent-1", apiKeyId: "key-1" }); + expect( + integrationPrincipalFromContext({ + userId: "issuer-user", + apiKey: { keyId: "key-2", linkedAgentId: null }, + }), + ).toEqual({ type: "API_KEY", apiKeyId: "key-2" }); + expect(() => integrationPrincipalFromContext({ userId: null, apiKey: null })).toThrow( + /exact integration principal/i, + ); + }); +}); From ec8ee3b8a08e8e7cf7b18e161b476b621dc3e06b Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:52:17 -0400 Subject: [PATCH 05/14] fix: enforce user API key lifecycle authorization --- src/server/routers/__tests__/apiKey.test.ts | 66 +++ .../__tests__/workspace-members.test.ts | 158 +++++++- src/server/routers/access.ts | 375 ++++++++++++++---- src/server/routers/workspace.ts | 61 +++ .../services/__tests__/api-key-auth.test.ts | 273 +++++++++++++ .../services/__tests__/user-lifecycle.test.ts | 106 +++++ src/server/services/api-key-auth.ts | 157 ++++++-- src/server/services/user-lifecycle.ts | 124 +++++- 8 files changed, 1187 insertions(+), 133 deletions(-) create mode 100644 src/server/services/__tests__/api-key-auth.test.ts diff --git a/src/server/routers/__tests__/apiKey.test.ts b/src/server/routers/__tests__/apiKey.test.ts index 495bd075..c7f2b4c1 100644 --- a/src/server/routers/__tests__/apiKey.test.ts +++ b/src/server/routers/__tests__/apiKey.test.ts @@ -300,3 +300,69 @@ describe("apiKey (access) router — narrowing", () => { expect(directDenied.items).toEqual([]); }); }); + +describe("apiKey (access) router — ownership and metadata", () => { + it("lets members manage only their own PERSONAL and SESSION keys", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "KAC" }); + fixtures.push(fixture); + const owner = accessRouter.createCaller(await buildContext(fixture)); + const member = accessRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + + const ownerKey = await owner.createPersonal({ + name: "owner personal", + scopes: ["READ_ISSUES"], + }); + const memberKey = await member.createSession({ + name: "member session", + scopes: ["READ_ISSUES"], + ttlHours: 2, + }); + + expect((await member.list()).map((key) => key.id)).toEqual([memberKey.id]); + await expect(member.revoke({ id: ownerKey.id })).rejects.toMatchObject({ code: "NOT_FOUND" }); + + const rotated = await member.rotate({ id: memberKey.id }); + expect(rotated.id).not.toBe(memberKey.id); + expect(rotated.kind).toBe("SESSION"); + expect((await owner.list()).map((key) => key.id)).toEqual( + expect.arrayContaining([ownerKey.id, memberKey.id, rotated.id]), + ); + }); + + it("does not let a non-admin mint an ADMIN-scoped user key", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "KAD" }); + fixtures.push(fixture); + const member = accessRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + + await expect( + member.createPersonal({ name: "escalation", scopes: ["READ_ISSUES", "ADMIN"] }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("records secret-free audit metadata for key lifecycle changes", async () => { + const { access, fixture } = await setup(); + const key = await access.createPersonal({ name: "audited", scopes: ["READ_ISSUES"] }); + await access.update({ id: key.id, name: "audited renamed" }); + await access.revoke({ id: key.id }); + + const rows = await getPrisma().auditLog.findMany({ + where: { workspaceId: fixture.workspace.id, entity: "ApiKey", entityId: key.id }, + orderBy: { createdAt: "asc" }, + select: { action: true, before: true, after: true }, + }); + expect(rows.map((row) => row.action)).toEqual(["create", "update", "revoke"]); + expect(JSON.stringify(rows)).not.toContain("hashedKey"); + expect(JSON.stringify(rows)).not.toContain("rawKey"); + }); + + it("requires AGENT keys to retain a linked agent identity", async () => { + const { access } = await setup(); + await expect( + access.create({ name: "unlinked", kind: "AGENT", scopes: ["READ_ISSUES"] }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); +}); diff --git a/src/server/routers/__tests__/workspace-members.test.ts b/src/server/routers/__tests__/workspace-members.test.ts index 58dcadd6..a9a62995 100644 --- a/src/server/routers/__tests__/workspace-members.test.ts +++ b/src/server/routers/__tests__/workspace-members.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect, afterAll, afterEach } from "vitest"; -import { AutoDispatchMode, DefaultIssueAssigneeMode, InvitationStatus, Role } from "@prisma/client"; +import { + AutoDispatchMode, + ConnectionProvider, + ConnectionStatus, + DefaultIssueAssigneeMode, + IntegrationCapability, + IntegrationCredentialSource, + IntegrationGrantScope, + IntegrationPrincipalType, + InvitationStatus, + Role, +} from "@prisma/client"; import { workspaceRouter } from "@/server/routers/workspace"; import { acceptWorkspaceInvitation, @@ -355,6 +366,125 @@ describe("workspaceRouter — admin member management", () => { it("removeMember deletes the membership and preserves the user row", async () => { const { caller, fixture } = await adminSetup(); const prisma = getPrisma(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `member-service-${Date.now()}`, + name: "Member-issued service", + }, + }); + const [personalKey, sessionKey, agentKey] = await Promise.all([ + prisma.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.secondUser.id, + name: "personal", + hashedKey: `member-personal-${Date.now()}`, + prefix: "member-personal", + kind: "PERSONAL", + scopes: ["READ_ISSUES"], + }, + }), + prisma.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.secondUser.id, + name: "session", + hashedKey: `member-session-${Date.now()}`, + prefix: "member-session", + kind: "SESSION", + scopes: ["READ_ISSUES"], + }, + }), + prisma.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.secondUser.id, + linkedAgentId: agent.id, + name: "agent", + hashedKey: `member-agent-${Date.now()}`, + prefix: "member-agent", + kind: "AGENT", + scopes: ["READ_ISSUES"], + }, + }), + ]); + const personalConnection = await prisma.connection.create({ + data: { + ownerId: fixture.secondUser.id, + provider: ConnectionProvider.GITHUB, + label: "Removed member personal GitHub", + status: ConnectionStatus.CONNECTED, + tokenEnc: "personal-token", + mappings: { + create: { + workspaceId: fixture.workspace.id, + kind: "repo", + target: "example/personal", + }, + }, + }, + include: { mappings: true }, + }); + const personalAuthorization = await prisma.connectionAuthorization.create({ + data: { + workspaceId: fixture.workspace.id, + connectionMappingId: personalConnection.mappings[0]!.id, + credentialSource: IntegrationCredentialSource.USER_CONNECTION, + capabilities: [IntegrationCapability.READ], + authorizedById: fixture.secondUser.id, + authorizationDigest: "removed-member-personal", + }, + }); + const personalGrant = await prisma.integrationGrant.create({ + data: { + workspaceId: fixture.workspace.id, + connectionAuthorizationId: personalAuthorization.id, + principalType: IntegrationPrincipalType.USER, + principalUserId: fixture.secondUser.id, + scope: IntegrationGrantScope.WORKSPACE, + capabilities: [IntegrationCapability.READ], + grantedById: fixture.user.id, + }, + }); + const app = await prisma.githubApp.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Removed member workspace app", + appId: "81234", + installationId: "85678", + privateKeyEnc: "workspace-app-private-key", + }, + }); + const appConnection = await prisma.connection.create({ + data: { + ownerId: fixture.secondUser.id, + provider: ConnectionProvider.GITHUB, + label: "Removed member workspace GitHub App", + status: ConnectionStatus.CONNECTED, + tokenEnc: "workspace-app-token", + config: { installationId: "85678" }, + mappings: { + create: { + workspaceId: fixture.workspace.id, + kind: "repo", + target: "example/workspace-app", + }, + }, + }, + include: { mappings: true }, + }); + const appAuthorization = await prisma.connectionAuthorization.create({ + data: { + workspaceId: fixture.workspace.id, + connectionMappingId: appConnection.mappings[0]!.id, + credentialSource: IntegrationCredentialSource.WORKSPACE_GITHUB_APP, + githubAppId: app.id, + capabilities: [IntegrationCapability.READ], + authorizedById: fixture.user.id, + authorizationDigest: "removed-member-workspace-app", + }, + }); const res = await caller.removeMember({ userId: fixture.secondUser.id }); expect(res.removed).toBe(true); @@ -373,6 +503,32 @@ describe("workspaceRouter — admin member management", () => { const user = await prisma.user.findUnique({ where: { id: fixture.secondUser.id } }); expect(user).toBeTruthy(); + const keys = await prisma.apiKey.findMany({ + where: { id: { in: [personalKey.id, sessionKey.id, agentKey.id] } }, + select: { id: true, revokedAt: true }, + }); + const revokedById = new Map(keys.map((key) => [key.id, key.revokedAt])); + expect(revokedById.get(personalKey.id)).toBeInstanceOf(Date); + expect(revokedById.get(sessionKey.id)).toBeInstanceOf(Date); + expect(revokedById.get(agentKey.id)).toBeNull(); + await expect( + prisma.connectionMapping.findUniqueOrThrow({ + where: { id: personalConnection.mappings[0]!.id }, + }), + ).resolves.toMatchObject({ status: "paused" }); + await expect( + prisma.connectionAuthorization.findUniqueOrThrow({ where: { id: personalAuthorization.id } }), + ).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect( + prisma.integrationGrant.findUniqueOrThrow({ where: { id: personalGrant.id } }), + ).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect( + prisma.connectionMapping.findUniqueOrThrow({ where: { id: appConnection.mappings[0]!.id } }), + ).resolves.toMatchObject({ status: "active" }); + await expect( + prisma.connectionAuthorization.findUniqueOrThrow({ where: { id: appAuthorization.id } }), + ).resolves.toMatchObject({ revokedAt: null }); + const event = await prisma.activityEvent.findFirst({ where: { workspaceId: fixture.workspace.id, diff --git a/src/server/routers/access.ts b/src/server/routers/access.ts index acb1ab49..5ed8bf91 100644 --- a/src/server/routers/access.ts +++ b/src/server/routers/access.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { PluginScope, ApiKeyKind } from "@prisma/client"; -import type { PrismaClient } from "@prisma/client"; +import type { ApiKey, Prisma, PrismaClient, Role } from "@prisma/client"; import { createHash, randomBytes } from "node:crypto"; import { router, adminProcedure, workspaceProcedure } from "@/server/trpc"; import { revokeAgentConnectionsForApiKey } from "@/server/services/agent-connection"; @@ -20,6 +20,98 @@ type NarrowIds = { initiativeIds?: string[]; }; +function isAdminRole(role: Role): boolean { + return role === "OWNER" || role === "ADMIN"; +} + +function isUserOwnedKey( + key: Pick, + userId: string, +): boolean { + return ( + key.userId === userId && + key.pluginId === null && + key.linkedAgentId === null && + (key.kind === ApiKeyKind.PERSONAL || key.kind === ApiKeyKind.SESSION) + ); +} + +function assertCanManageKey( + key: Pick, + userId: string, + role: Role, +): void { + if (isAdminRole(role) || isUserOwnedKey(key, userId)) return; + throw new TRPCError({ code: "NOT_FOUND" }); +} + +function assertUserScopesAllowed(scopes: PluginScope[], role: Role): void { + if (scopes.includes(PluginScope.ADMIN) && !isAdminRole(role)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only workspace administrators may issue an ADMIN-scoped key.", + }); + } +} + +async function auditApiKey( + tx: Prisma.TransactionClient, + input: { + workspaceId: string; + actorId: string; + keyId: string; + action: string; + before?: Prisma.InputJsonValue; + after?: Prisma.InputJsonValue; + ip?: string | null; + userAgent?: string | null; + }, +): Promise { + await tx.auditLog.create({ + data: { + workspaceId: input.workspaceId, + actorId: input.actorId, + entity: "ApiKey", + entityId: input.keyId, + action: input.action, + before: input.before, + after: input.after, + ip: input.ip ?? undefined, + userAgent: input.userAgent ?? undefined, + }, + }); +} + +function keyAuditMetadata(key: { + name: string; + prefix: string; + kind: ApiKeyKind; + scopes: PluginScope[]; + projectIds: string[]; + labelIds: string[]; + initiativeIds: string[]; + linkedAgentId: string | null; + userId: string | null; + pluginId: string | null; + expiresAt: Date | null; + revokedAt?: Date | null; +}): Prisma.InputJsonValue { + return { + name: key.name, + prefix: key.prefix, + kind: key.kind, + scopes: key.scopes, + projectIds: key.projectIds, + labelIds: key.labelIds, + initiativeIds: key.initiativeIds, + linkedAgentId: key.linkedAgentId, + userId: key.userId, + pluginId: key.pluginId, + expiresAt: key.expiresAt?.toISOString() ?? null, + revokedAt: key.revokedAt?.toISOString() ?? null, + }; +} + /** * Confirm every id in `ids` belongs to `workspaceId` for the given entity. * Used when creating/updating API keys with narrowing — we don't want a @@ -115,7 +207,17 @@ async function assertAgentInWorkspace( export const accessRouter = router({ list: workspaceProcedure.query(async ({ ctx }) => ctx.db.apiKey.findMany({ - where: { workspaceId: ctx.workspaceId, pluginId: null }, + where: { + workspaceId: ctx.workspaceId, + pluginId: null, + ...(isAdminRole(ctx.membership.role) + ? {} + : { + userId: ctx.session.user.id, + linkedAgentId: null, + kind: { in: [ApiKeyKind.PERSONAL, ApiKeyKind.SESSION] }, + }), + }, orderBy: { createdAt: "desc" }, select: { id: true, @@ -177,23 +279,47 @@ export const accessRouter = router({ await assertAgentInWorkspace(ctx.db, ctx.workspaceId, input.linkedAgentId); const { raw, hashed, prefix } = generateApiKey(); const inferredKind: ApiKeyKind = input.kind ?? (input.linkedAgentId ? "AGENT" : "PERSONAL"); - const row = await ctx.db.apiKey.create({ - data: { + if (inferredKind === ApiKeyKind.AGENT && !input.linkedAgentId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "AGENT keys must be linked to an agent.", + }); + } + if (inferredKind !== ApiKeyKind.AGENT && input.linkedAgentId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${inferredKind} keys cannot be linked to an agent.`, + }); + } + const row = await ctx.db.$transaction(async (tx) => { + const created = await tx.apiKey.create({ + data: { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + kind: inferredKind, + name: input.name, + hashedKey: hashed, + prefix, + scopes: input.scopes, + projectIds: input.projectIds, + labelIds: input.labelIds, + initiativeIds: input.initiativeIds, + linkedAgentId: input.linkedAgentId ?? null, + expiresAt: input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * 86_400_000) + : undefined, + }, + }); + await auditApiKey(tx, { workspaceId: ctx.workspaceId, - userId: ctx.session.user.id, - kind: inferredKind, - name: input.name, - hashedKey: hashed, - prefix, - scopes: input.scopes, - projectIds: input.projectIds, - labelIds: input.labelIds, - initiativeIds: input.initiativeIds, - linkedAgentId: input.linkedAgentId ?? null, - expiresAt: input.expiresInDays - ? new Date(Date.now() + input.expiresInDays * 86_400_000) - : undefined, - }, + actorId: ctx.session.user.id, + keyId: created.id, + action: "create", + after: keyAuditMetadata(created), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return created; }); // rawKey returned once, never persisted. return { @@ -217,7 +343,7 @@ export const accessRouter = router({ * for a new secret, call `rotate`. Scope array is also immutable here; * shrinking/expanding scopes means issuing a new key. */ - update: adminProcedure + update: workspaceProcedure .input( z.object({ id: z.string().cuid(), @@ -233,65 +359,109 @@ export const accessRouter = router({ where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!prior) throw new TRPCError({ code: "NOT_FOUND" }); + assertCanManageKey(prior, ctx.session.user.id, ctx.membership.role); await assertIdsInWorkspace(ctx.db, ctx.workspaceId, { projectIds: input.projectIds, labelIds: input.labelIds, initiativeIds: input.initiativeIds, }); if (input.linkedAgentId !== undefined) { + if (prior.kind !== ApiKeyKind.AGENT) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${prior.kind} keys cannot be linked to an agent.`, + }); + } await assertAgentInWorkspace(ctx.db, ctx.workspaceId, input.linkedAgentId); + if (!input.linkedAgentId) { + throw new TRPCError({ code: "BAD_REQUEST", message: "AGENT keys must remain linked." }); + } } - return ctx.db.apiKey.update({ - where: { id: prior.id }, - data: { - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.projectIds !== undefined ? { projectIds: input.projectIds } : {}), - ...(input.labelIds !== undefined ? { labelIds: input.labelIds } : {}), - ...(input.initiativeIds !== undefined ? { initiativeIds: input.initiativeIds } : {}), - ...(input.linkedAgentId !== undefined ? { linkedAgentId: input.linkedAgentId } : {}), - }, - select: { - id: true, - name: true, - prefix: true, - scopes: true, - projectIds: true, - labelIds: true, - initiativeIds: true, - linkedAgentId: true, - createdAt: true, - expiresAt: true, - revokedAt: true, - }, + return ctx.db.$transaction(async (tx) => { + const updated = await tx.apiKey.update({ + where: { id: prior.id }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.projectIds !== undefined ? { projectIds: input.projectIds } : {}), + ...(input.labelIds !== undefined ? { labelIds: input.labelIds } : {}), + ...(input.initiativeIds !== undefined ? { initiativeIds: input.initiativeIds } : {}), + ...(input.linkedAgentId !== undefined ? { linkedAgentId: input.linkedAgentId } : {}), + }, + }); + await auditApiKey(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + keyId: updated.id, + action: "update", + before: keyAuditMetadata(prior), + after: keyAuditMetadata(updated), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return { + id: updated.id, + name: updated.name, + prefix: updated.prefix, + scopes: updated.scopes, + projectIds: updated.projectIds, + labelIds: updated.labelIds, + initiativeIds: updated.initiativeIds, + linkedAgentId: updated.linkedAgentId, + createdAt: updated.createdAt, + expiresAt: updated.expiresAt, + revokedAt: updated.revokedAt, + }; }); }), - revoke: adminProcedure + revoke: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const key = await ctx.db.apiKey.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, + where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!key) throw new TRPCError({ code: "NOT_FOUND" }); + assertCanManageKey(key, ctx.session.user.id, ctx.membership.role); const revokedAt = new Date(); return ctx.db.$transaction(async (tx) => { await revokeAgentConnectionsForApiKey(tx, key.id, revokedAt); - return tx.apiKey.update({ + const revoked = await tx.apiKey.update({ where: { id: input.id }, data: { revokedAt }, }); + await auditApiKey(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + keyId: revoked.id, + action: "revoke", + before: keyAuditMetadata(key), + after: keyAuditMetadata(revoked), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return revoked; }); }), - delete: adminProcedure + delete: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const key = await ctx.db.apiKey.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!key) throw new TRPCError({ code: "NOT_FOUND" }); + assertCanManageKey(key, ctx.session.user.id, ctx.membership.role); return ctx.db.$transaction(async (tx) => { await revokeAgentConnectionsForApiKey(tx, key.id); + await auditApiKey(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + keyId: key.id, + action: "delete", + before: keyAuditMetadata(key), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); return tx.apiKey.delete({ where: { id: input.id } }); }); }), @@ -301,7 +471,7 @@ export const accessRouter = router({ * until revoked. Suitable for local Claude Code sessions, scripts, or * one-off integrations. */ - createPersonal: adminProcedure + createPersonal: workspaceProcedure .input( z.object({ name: z.string().min(1).max(80), @@ -313,29 +483,42 @@ export const accessRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + assertUserScopesAllowed(input.scopes, ctx.membership.role); await assertIdsInWorkspace(ctx.db, ctx.workspaceId, { projectIds: input.projectIds, labelIds: input.labelIds, initiativeIds: input.initiativeIds, }); const { raw, hashed, prefix } = generateApiKey(); - const row = await ctx.db.apiKey.create({ - data: { + const row = await ctx.db.$transaction(async (tx) => { + const created = await tx.apiKey.create({ + data: { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + kind: "PERSONAL" as const, + name: input.name, + hashedKey: hashed, + prefix, + scopes: input.scopes, + projectIds: input.projectIds, + labelIds: input.labelIds, + initiativeIds: input.initiativeIds, + linkedAgentId: null, + expiresAt: input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * 86_400_000) + : null, + }, + }); + await auditApiKey(tx, { workspaceId: ctx.workspaceId, - userId: ctx.session.user.id, - kind: "PERSONAL" as const, - name: input.name, - hashedKey: hashed, - prefix, - scopes: input.scopes, - projectIds: input.projectIds, - labelIds: input.labelIds, - initiativeIds: input.initiativeIds, - linkedAgentId: null, - expiresAt: input.expiresInDays - ? new Date(Date.now() + input.expiresInDays * 86_400_000) - : null, - }, + actorId: ctx.session.user.id, + keyId: created.id, + action: "create", + after: keyAuditMetadata(created), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return created; }); return { ...row, rawKey: raw }; }), @@ -344,7 +527,7 @@ export const accessRouter = router({ * Create a TTL-bounded session key. Expires automatically via `expiresAt`. * Perfect for ephemeral sessions or one-off tasks. */ - createSession: adminProcedure + createSession: workspaceProcedure .input( z.object({ name: z.string().min(1).max(80), @@ -356,6 +539,7 @@ export const accessRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + assertUserScopesAllowed(input.scopes, ctx.membership.role); await assertIdsInWorkspace(ctx.db, ctx.workspaceId, { projectIds: input.projectIds, labelIds: input.labelIds, @@ -363,21 +547,33 @@ export const accessRouter = router({ }); const { raw, hashed, prefix } = generateApiKey(); const expiresAt = new Date(Date.now() + input.ttlHours * 3_600_000); - const row = await ctx.db.apiKey.create({ - data: { + const row = await ctx.db.$transaction(async (tx) => { + const created = await tx.apiKey.create({ + data: { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + kind: "SESSION" as const, + name: input.name, + hashedKey: hashed, + prefix, + scopes: input.scopes, + projectIds: input.projectIds, + labelIds: input.labelIds, + initiativeIds: input.initiativeIds, + linkedAgentId: null, + expiresAt, + }, + }); + await auditApiKey(tx, { workspaceId: ctx.workspaceId, - userId: ctx.session.user.id, - kind: "SESSION" as const, - name: input.name, - hashedKey: hashed, - prefix, - scopes: input.scopes, - projectIds: input.projectIds, - labelIds: input.labelIds, - initiativeIds: input.initiativeIds, - linkedAgentId: null, - expiresAt, - }, + actorId: ctx.session.user.id, + keyId: created.id, + action: "create", + after: keyAuditMetadata(created), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return created; }); return { ...row, rawKey: raw }; }), @@ -387,13 +583,14 @@ export const accessRouter = router({ * same name + scopes + expiry window (if any). Returns the raw key once. * Consumers must update their stored credential. */ - rotate: adminProcedure + rotate: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const prior = await ctx.db.apiKey.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId, pluginId: null }, }); if (!prior) throw new TRPCError({ code: "NOT_FOUND" }); + assertCanManageKey(prior, ctx.session.user.id, ctx.membership.role); if (prior.revokedAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Key already revoked." }); @@ -402,7 +599,7 @@ export const accessRouter = router({ const next = await ctx.db.$transaction(async (tx) => { await revokeAgentConnectionsForApiKey(tx, prior.id, revokedAt); await tx.apiKey.update({ where: { id: prior.id }, data: { revokedAt } }); - return tx.apiKey.create({ + const created = await tx.apiKey.create({ data: { workspaceId: ctx.workspaceId, userId: prior.userId, @@ -424,6 +621,26 @@ export const accessRouter = router({ : null, }, }); + await auditApiKey(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + keyId: prior.id, + action: "rotate-from", + before: keyAuditMetadata(prior), + after: keyAuditMetadata({ ...prior, revokedAt }), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + await auditApiKey(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + keyId: created.id, + action: "rotate-to", + after: keyAuditMetadata(created), + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return created; }); return { id: next.id, diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index 99d2bcb4..6753d1d2 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -1317,7 +1317,64 @@ export const workspaceRouter = router({ } } + const revokedAt = new Date(); + const personalMappings = await tx.connectionMapping.findMany({ + where: { + workspaceId: ctx.workspaceId, + connection: { ownerId: target.userId }, + authorization: { + credentialSource: "USER_CONNECTION", + revokedAt: null, + }, + }, + select: { id: true, authorization: { select: { id: true } } }, + }); + const personalMappingIds = personalMappings.map((mapping) => mapping.id); + const personalAuthorizationIds = personalMappings.flatMap((mapping) => + mapping.authorization ? [mapping.authorization.id] : [], + ); + const [revokedIntegrationGrants, revokedConnectionAuthorizations, pausedMappings] = + await Promise.all([ + tx.integrationGrant.updateMany({ + where: { + workspaceId: ctx.workspaceId, + principalType: "USER", + principalUserId: target.userId, + revokedAt: null, + }, + data: { revokedAt, revokedById: ctx.session.user.id }, + }), + personalAuthorizationIds.length + ? tx.connectionAuthorization.updateMany({ + where: { + id: { in: personalAuthorizationIds }, + workspaceId: ctx.workspaceId, + credentialSource: "USER_CONNECTION", + revokedAt: null, + }, + data: { revokedAt, revokedById: ctx.session.user.id }, + }) + : Promise.resolve({ count: 0 }), + personalMappingIds.length + ? tx.connectionMapping.updateMany({ + where: { id: { in: personalMappingIds }, workspaceId: ctx.workspaceId }, + data: { status: "paused" }, + }) + : Promise.resolve({ count: 0 }), + ]); + await tx.membership.delete({ where: { id: target.id } }); + const revokedUserKeys = await tx.apiKey.updateMany({ + where: { + workspaceId: ctx.workspaceId, + userId: target.userId, + kind: { in: ["PERSONAL", "SESSION"] }, + pluginId: null, + linkedAgentId: null, + revokedAt: null, + }, + data: { revokedAt }, + }); await tx.workspace.updateMany({ where: { id: ctx.workspaceId, @@ -1344,6 +1401,10 @@ export const workspaceRouter = router({ userId: target.userId, email: target.user.email, role: target.role, + revokedUserApiKeys: revokedUserKeys.count, + revokedIntegrationGrants: revokedIntegrationGrants.count, + revokedConnectionAuthorizations: revokedConnectionAuthorizations.count, + pausedPersonalConnectionMappings: pausedMappings.count, }, ip: ctx.ip, userAgent: ctx.userAgent, diff --git a/src/server/services/__tests__/api-key-auth.test.ts b/src/server/services/__tests__/api-key-auth.test.ts new file mode 100644 index 00000000..dacc01ed --- /dev/null +++ b/src/server/services/__tests__/api-key-auth.test.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { ApiKeyKind, PluginScope, PluginStatus, UserStatus } from "@prisma/client"; +import { assertKeyScope, authenticateApiKey } from "@/server/services/api-key-auth"; +import type { ApiKeyError } from "@/server/services/api-key-auth"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; +let sequence = 0; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +async function createKey( + fixture: TestFixture, + input: { + kind: ApiKeyKind; + userId?: string | null; + pluginId?: string | null; + linkedAgentId?: string | null; + scopes?: PluginScope[]; + }, +) { + const raw = `forge_sk_auth-test-${Date.now()}-${sequence++}`; + await getPrisma().apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + name: `auth test ${sequence}`, + hashedKey: createHash("sha256").update(raw).digest("hex"), + prefix: raw.slice(0, 18), + kind: input.kind, + userId: input.userId ?? null, + pluginId: input.pluginId ?? null, + linkedAgentId: input.linkedAgentId ?? null, + scopes: input.scopes ?? [PluginScope.READ_ISSUES], + }, + }); + return raw; +} + +describe("authenticateApiKey user-principal revalidation", () => { + it("resolves a live PERSONAL key with its current membership role", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUP" }); + fixtures.push(fixture); + const raw = await createKey(fixture, { + kind: ApiKeyKind.PERSONAL, + userId: fixture.user.id, + scopes: [PluginScope.READ_ISSUES, PluginScope.ADMIN], + }); + + const principal = await authenticateApiKey(raw); + expect(principal).toMatchObject({ + principalType: "USER", + kind: ApiKeyKind.PERSONAL, + userId: fixture.user.id, + membershipRole: "OWNER", + }); + expect(principal.scopes).toContain(PluginScope.ADMIN); + }); + + it("strips effective ADMIN after a role demotion", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUD" }); + fixtures.push(fixture); + const raw = await createKey(fixture, { + kind: ApiKeyKind.SESSION, + userId: fixture.secondUser.id, + scopes: [PluginScope.READ_ISSUES, PluginScope.ADMIN], + }); + + const principal = await authenticateApiKey(raw); + expect(principal.membershipRole).toBe("MEMBER"); + expect(principal.scopes).toEqual([PluginScope.READ_ISSUES]); + await expect(authenticateApiKey(raw, [PluginScope.ADMIN])).rejects.toMatchObject({ + status: 403, + } satisfies Partial); + }); + + it.each([ + [UserStatus.INVITED, null, null], + [UserStatus.SUSPENDED, new Date(), null], + [UserStatus.DELETED, new Date(), new Date()], + ])( + "rejects a %s user even when the key row remains live", + async (status, disabledAt, deletedAt) => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUS" }); + fixtures.push(fixture); + const raw = await createKey(fixture, { + kind: ApiKeyKind.PERSONAL, + userId: fixture.secondUser.id, + }); + await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { status, disabledAt, deletedAt }, + }); + + await expect(authenticateApiKey(raw)).rejects.toMatchObject({ status: 403 }); + }, + ); + + it("rejects a user key immediately after workspace membership removal", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUM" }); + fixtures.push(fixture); + const raw = await createKey(fixture, { + kind: ApiKeyKind.SESSION, + userId: fixture.secondUser.id, + }); + await getPrisma().membership.delete({ + where: { + userId_workspaceId: { + userId: fixture.secondUser.id, + workspaceId: fixture.workspace.id, + }, + }, + }); + + await expect(authenticateApiKey(raw)).rejects.toMatchObject({ status: 403 }); + }); + + it("rejects a malformed user key that is linked to an agent", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUX" }); + fixtures.push(fixture); + const agent = await getPrisma().agent.create({ + data: { workspaceId: fixture.workspace.id, profileKey: `auth-${sequence}`, name: "Auth" }, + }); + const raw = await createKey(fixture, { + kind: ApiKeyKind.PERSONAL, + userId: fixture.user.id, + linkedAgentId: agent.id, + }); + + await expect(authenticateApiKey(raw)).rejects.toMatchObject({ status: 401 }); + }); + + it("revalidates ProjectAccess live while retaining key narrowing as a ceiling", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUG" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const [allowed, other] = await Promise.all([ + prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: `G${sequence++}`, + name: "Granted", + visibility: "RESTRICTED", + createdById: fixture.user.id, + }, + }), + prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: `H${sequence++}`, + name: "Other", + visibility: "RESTRICTED", + createdById: fixture.user.id, + }, + }), + ]); + const raw = await createKey(fixture, { + kind: ApiKeyKind.PERSONAL, + userId: fixture.secondUser.id, + }); + const principal = await authenticateApiKey(raw); + const scopeContext = { apiKey: { ...principal, projectIds: [allowed.id] }, db: prisma }; + + await expect( + assertKeyScope(scopeContext, { entity: "project", id: allowed.id }), + ).rejects.toThrow(/scope/i); + const membership = await prisma.membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: fixture.secondUser.id, + workspaceId: fixture.workspace.id, + }, + }, + }); + const grant = await prisma.projectAccess.create({ + data: { + workspaceId: fixture.workspace.id, + projectId: allowed.id, + membershipId: membership.id, + role: "VIEWER", + grantedById: fixture.user.id, + }, + }); + await expect( + assertKeyScope(scopeContext, { entity: "project", id: allowed.id }), + ).resolves.toBeUndefined(); + await expect(assertKeyScope(scopeContext, { entity: "project", id: other.id })).rejects.toThrow( + /scope/i, + ); + + await prisma.projectAccess.delete({ where: { id: grant.id } }); + await expect( + assertKeyScope(scopeContext, { entity: "project", id: allowed.id }), + ).rejects.toThrow(/scope/i); + }); +}); + +describe("authenticateApiKey service principals", () => { + it("keeps an AGENT key independent from its issuer lifecycle and membership", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUA" }); + fixtures.push(fixture); + const agent = await getPrisma().agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `service-${sequence}`, + name: "Service", + }, + }); + const raw = await createKey(fixture, { + kind: ApiKeyKind.AGENT, + userId: fixture.secondUser.id, + linkedAgentId: agent.id, + scopes: [PluginScope.ADMIN], + }); + await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { status: UserStatus.SUSPENDED, disabledAt: new Date() }, + }); + await getPrisma().membership.delete({ + where: { + userId_workspaceId: { + userId: fixture.secondUser.id, + workspaceId: fixture.workspace.id, + }, + }, + }); + + const principal = await authenticateApiKey(raw, [PluginScope.ADMIN]); + expect(principal).toMatchObject({ + principalType: "AGENT", + membershipRole: null, + linkedAgentId: agent.id, + }); + }); + + it("keeps PLUGIN authorization tied to plugin approval", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "AUL" }); + fixtures.push(fixture); + const plugin = await getPrisma().plugin.create({ + data: { + workspaceId: fixture.workspace.id, + slug: `auth-${sequence}`, + name: "Auth plugin", + version: "1.0.0", + manifest: {}, + scopes: [PluginScope.READ_ISSUES], + status: PluginStatus.APPROVED, + }, + }); + const raw = await createKey(fixture, { + kind: ApiKeyKind.AGENT, + pluginId: plugin.id, + }); + expect(await authenticateApiKey(raw)).toMatchObject({ principalType: "PLUGIN" }); + + await getPrisma().plugin.update({ + where: { id: plugin.id }, + data: { status: PluginStatus.SUSPENDED }, + }); + await expect(authenticateApiKey(raw)).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/src/server/services/__tests__/user-lifecycle.test.ts b/src/server/services/__tests__/user-lifecycle.test.ts index a9eb77c1..d3fbdf1e 100644 --- a/src/server/services/__tests__/user-lifecycle.test.ts +++ b/src/server/services/__tests__/user-lifecycle.test.ts @@ -1,6 +1,10 @@ import { ConnectionProvider, ConnectionStatus, + IntegrationCapability, + IntegrationCredentialSource, + IntegrationGrantScope, + IntegrationPrincipalType, InstanceRole, PluginScope, Role, @@ -175,6 +179,26 @@ describe("user lifecycle", () => { name: "Lifecycle test", hashedKey: `hash-${Date.now()}`, prefix: "frg_test", + kind: "PERSONAL", + scopes: [PluginScope.READ_ISSUES], + }, + }); + const agent = await db.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `lifecycle-service-${Date.now()}`, + name: "Lifecycle service", + }, + }); + const serviceKey = await db.apiKey.create({ + data: { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + linkedAgentId: agent.id, + name: "Lifecycle service key", + hashedKey: `service-hash-${Date.now()}`, + prefix: "frg_service", + kind: "AGENT", scopes: [PluginScope.READ_ISSUES], }, }); @@ -195,6 +219,65 @@ describe("user lifecycle", () => { }, include: { mappings: true }, }); + const personalAuthorization = await db.connectionAuthorization.create({ + data: { + workspaceId: fixture.workspace.id, + connectionMappingId: connection.mappings[0]!.id, + credentialSource: IntegrationCredentialSource.USER_CONNECTION, + capabilities: [IntegrationCapability.READ], + authorizedById: fixture.user.id, + authorizationDigest: "personal-lifecycle", + }, + }); + const personalGrant = await db.integrationGrant.create({ + data: { + workspaceId: fixture.workspace.id, + connectionAuthorizationId: personalAuthorization.id, + principalType: IntegrationPrincipalType.USER, + principalUserId: fixture.user.id, + scope: IntegrationGrantScope.WORKSPACE, + capabilities: [IntegrationCapability.READ], + grantedById: fixture.user.id, + }, + }); + const app = await db.githubApp.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Lifecycle app", + appId: "12345", + installationId: "67890", + privateKeyEnc: "encrypted-private-key", + }, + }); + const appConnection = await db.connection.create({ + data: { + ownerId: fixture.user.id, + provider: ConnectionProvider.GITHUB, + label: "Workspace GitHub App", + status: ConnectionStatus.CONNECTED, + tokenEnc: "app-token-must-survive", + config: { installationId: "67890" }, + mappings: { + create: { + workspaceId: fixture.workspace.id, + kind: "repo", + target: "example/app-repo", + }, + }, + }, + include: { mappings: true }, + }); + const appAuthorization = await db.connectionAuthorization.create({ + data: { + workspaceId: fixture.workspace.id, + connectionMappingId: appConnection.mappings[0]!.id, + credentialSource: IntegrationCredentialSource.WORKSPACE_GITHUB_APP, + githubAppId: app.id, + capabilities: [IntegrationCapability.READ], + authorizedById: fixture.user.id, + authorizationDigest: "workspace-app-lifecycle", + }, + }); const suspended = await suspendUser(db, { actorId: fixture.secondUser.id, @@ -207,11 +290,16 @@ describe("user lifecycle", () => { apiKeys: 1, connections: 1, mappings: 1, + connectionAuthorizations: 1, + integrationGrants: 1, }); await expect(db.session.count({ where: { userId: fixture.user.id } })).resolves.toBe(0); await expect(db.apiKey.findUniqueOrThrow({ where: { id: key.id } })).resolves.toMatchObject({ revokedAt: expect.any(Date), }); + await expect( + db.apiKey.findUniqueOrThrow({ where: { id: serviceKey.id } }), + ).resolves.toMatchObject({ revokedAt: null }); await expect( db.connection.findUniqueOrThrow({ where: { id: connection.id } }), ).resolves.toMatchObject({ @@ -221,6 +309,24 @@ describe("user lifecycle", () => { await expect( db.connectionMapping.findUniqueOrThrow({ where: { id: connection.mappings[0]!.id } }), ).resolves.toMatchObject({ status: "paused" }); + await expect( + db.connectionAuthorization.findUniqueOrThrow({ where: { id: personalAuthorization.id } }), + ).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect( + db.integrationGrant.findUniqueOrThrow({ where: { id: personalGrant.id } }), + ).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect( + db.connection.findUniqueOrThrow({ where: { id: appConnection.id } }), + ).resolves.toMatchObject({ + status: ConnectionStatus.CONNECTED, + tokenEnc: "app-token-must-survive", + }); + await expect( + db.connectionMapping.findUniqueOrThrow({ where: { id: appConnection.mappings[0]!.id } }), + ).resolves.toMatchObject({ status: "active" }); + await expect( + db.connectionAuthorization.findUniqueOrThrow({ where: { id: appAuthorization.id } }), + ).resolves.toMatchObject({ revokedAt: null }); const active = await reactivateUser(db, { actorId: fixture.secondUser.id, diff --git a/src/server/services/api-key-auth.ts b/src/server/services/api-key-auth.ts index 48912060..23fc8117 100644 --- a/src/server/services/api-key-auth.ts +++ b/src/server/services/api-key-auth.ts @@ -2,7 +2,7 @@ import "server-only"; import { createHash } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { db } from "@/server/db"; -import type { PluginScope } from "@prisma/client"; +import { ApiKeyKind, PluginScope, type Role } from "@prisma/client"; export class ApiKeyError extends Error { constructor( @@ -21,7 +21,13 @@ export class ApiKeyError extends Error { export interface ApiKeyContext { keyId: string; workspaceId: string; + /** Populated by authenticateApiKey; optional only for legacy internal test contexts. */ + kind?: ApiKeyKind; + principalType?: "USER" | "AGENT" | "PLUGIN"; userId: string | null; + /** Current workspace role for user principals; service principals do not inherit issuer roles. */ + membershipId?: string | null; + membershipRole?: Role | null; pluginId: string | null; scopes: PluginScope[]; projectIds: string[]; @@ -42,6 +48,23 @@ export function hasApiKeyNarrowing(ctx: { apiKey?: ApiKeyContext | null }): bool return Boolean(key && (key.projectIds.length || key.labelIds.length || key.initiativeIds.length)); } +function userProjectAccessWhere(key: ApiKeyContext): Record | null { + if (key.principalType !== "USER") return null; + if (!key.membershipId || !key.membershipRole) return { id: "__user_membership_denied__" }; + if (key.membershipRole === "OWNER" || key.membershipRole === "ADMIN") return null; + const explicit = { + accessGrants: { + some: { + membershipId: key.membershipId, + role: { in: ["VIEWER", "CONTRIBUTOR", "MANAGER"] }, + }, + }, + }; + return key.membershipRole === "MEMBER" + ? { OR: [{ visibility: "WORKSPACE" }, explicit] } + : explicit; +} + /** * Authenticate an incoming plugin/agent request from its `Authorization: Bearer `. * Enforces revocation, expiry, and required scopes. Updates `lastUsedAt` lazily. @@ -56,7 +79,19 @@ export async function authenticateApiKey( const hashed = createHash("sha256").update(raw).digest("hex"); const key = await db.apiKey.findUnique({ where: { hashedKey: hashed }, - include: { plugin: true }, + include: { + plugin: true, + user: { + select: { + status: true, + disabledAt: true, + deletedAt: true, + memberships: { + select: { id: true, workspaceId: true, role: true }, + }, + }, + }, + }, }); if (!key) throw new ApiKeyError("Invalid API key.", 401); if (key.revokedAt) throw new ApiKeyError("API key revoked.", 401); @@ -64,8 +99,45 @@ export async function authenticateApiKey( if (key.plugin && key.plugin.status !== "APPROVED") throw new ApiKeyError("Plugin not approved.", 403); + const principalType: ApiKeyContext["principalType"] = key.pluginId + ? "PLUGIN" + : key.kind === ApiKeyKind.PERSONAL || key.kind === ApiKeyKind.SESSION + ? "USER" + : "AGENT"; + let membershipRole: Role | null = null; + let membershipId: string | null = null; + if (principalType === "USER") { + // PERSONAL and SESSION credentials are alternate sessions for a human, + // not durable service principals. Re-resolve their account and workspace + // authority on every request so suspension, deletion, and membership + // changes take effect immediately. + if (!key.userId || key.pluginId || key.linkedAgentId) { + throw new ApiKeyError("Invalid user API key.", 401); + } + if (!key.user || key.user.status !== "ACTIVE" || key.user.disabledAt || key.user.deletedAt) { + throw new ApiKeyError("API key owner is not active.", 403); + } + const membership = key.user.memberships.find( + (candidate) => candidate.workspaceId === key.workspaceId, + ); + membershipId = membership?.id ?? null; + membershipRole = membership?.role ?? null; + if (!membershipRole) { + throw new ApiKeyError("API key owner is not a workspace member.", 403); + } + } + + // ADMIN on a user key is only a requested ceiling. A later role demotion + // removes it from the effective request context without changing the + // durable key metadata. AGENT and PLUGIN keys retain service-principal + // semantics and continue to be governed by their stored scopes. + const scopes = + principalType === "USER" && membershipRole !== "OWNER" && membershipRole !== "ADMIN" + ? key.scopes.filter((scope) => scope !== PluginScope.ADMIN) + : key.scopes; + for (const s of required) { - if (!key.scopes.includes(s)) throw new ApiKeyError(`Missing required scope: ${s}`, 403); + if (!scopes.includes(s)) throw new ApiKeyError(`Missing required scope: ${s}`, 403); } // Non-blocking last-used update. Batch in production via a queue. @@ -76,9 +148,13 @@ export async function authenticateApiKey( return { keyId: key.id, workspaceId: key.workspaceId, + kind: key.kind, + principalType, pluginId: key.pluginId, userId: key.userId, - scopes: key.scopes, + membershipId, + membershipRole, + scopes, projectIds: key.projectIds, labelIds: key.labelIds, initiativeIds: key.initiativeIds, @@ -105,8 +181,13 @@ export async function assertKeyScope( if (!key) return; switch (opts.entity) { case "project": { - if (!key.projectIds.length) return; - if (!key.projectIds.includes(opts.id)) { + const scope = buildKeyScopeWhere(ctx, "project"); + if (!Object.keys(scope).length) return; + const project = await ctx.db.project.findFirst({ + where: { id: opts.id, workspaceId: key.workspaceId, deletedAt: null, ...scope }, + select: { id: true }, + }); + if (!project) { throw new TRPCError({ code: "FORBIDDEN", message: "API key scope does not include this resource.", @@ -125,29 +206,13 @@ export async function assertKeyScope( return; } case "issue": { - const hasProject = key.projectIds.length > 0; - const hasLabel = key.labelIds.length > 0; - const hasInitiative = key.initiativeIds.length > 0; - if (!hasProject && !hasLabel && !hasInitiative) return; - const issue = await ctx.db.issue.findUnique({ - where: { id: opts.id }, - select: { - projectId: true, - project: { select: { initiativeId: true } }, - labels: { select: { labelId: true } }, - }, + const scope = buildKeyScopeWhere(ctx, "issue"); + if (!Object.keys(scope).length) return; + const issue = await ctx.db.issue.findFirst({ + where: { id: opts.id, workspaceId: key.workspaceId, deletedAt: null, ...scope }, + select: { id: true }, }); if (!issue) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - const projectOk = - hasProject && issue.projectId ? key.projectIds.includes(issue.projectId) : false; - const labelOk = hasLabel && issue.labels.some((l) => key.labelIds.includes(l.labelId)); - const initiativeOk = - hasInitiative && issue.project?.initiativeId - ? key.initiativeIds.includes(issue.project.initiativeId) - : false; - if (!projectOk && !labelOk && !initiativeOk) { throw new TRPCError({ code: "FORBIDDEN", message: "API key scope does not include this resource.", @@ -171,26 +236,46 @@ export function buildKeyScopeWhere( if (!key) return {}; switch (entity) { case "project": { - if (!key.projectIds.length) return {}; - return { id: { in: key.projectIds } }; + const clauses: Record[] = []; + if (key.projectIds.length) clauses.push({ id: { in: key.projectIds } }); + const userAccess = userProjectAccessWhere(key); + if (userAccess) clauses.push(userAccess); + if (!clauses.length) return {}; + return clauses.length === 1 ? clauses[0]! : { AND: clauses }; } case "initiative": { if (!key.initiativeIds.length) return {}; return { id: { in: key.initiativeIds } }; } case "issue": { - const clauses: Record[] = []; + const keyClauses: Record[] = []; if (key.projectIds.length) { - clauses.push({ projectId: { in: key.projectIds } }); + keyClauses.push({ projectId: { in: key.projectIds } }); } if (key.labelIds.length) { - clauses.push({ labels: { some: { labelId: { in: key.labelIds } } } }); + keyClauses.push({ labels: { some: { labelId: { in: key.labelIds } } } }); } if (key.initiativeIds.length) { - clauses.push({ project: { initiativeId: { in: key.initiativeIds } } }); + keyClauses.push({ project: { initiativeId: { in: key.initiativeIds } } }); + } + const clauses: Record[] = []; + if (keyClauses.length) + clauses.push(keyClauses.length === 1 ? keyClauses[0]! : { OR: keyClauses }); + const userProjectAccess = userProjectAccessWhere(key); + if (key.principalType === "USER") { + const canUseUnfiled = + key.membershipRole === "OWNER" || + key.membershipRole === "ADMIN" || + key.membershipRole === "MEMBER"; + clauses.push({ + OR: [ + ...(canUseUnfiled ? [{ projectId: null }] : []), + ...(userProjectAccess ? [{ project: userProjectAccess }] : [{ project: {} }]), + ], + }); } if (!clauses.length) return {}; - return clauses.length === 1 ? clauses[0] : { OR: clauses }; + return clauses.length === 1 ? clauses[0]! : { AND: clauses }; } } } @@ -209,6 +294,10 @@ export function buildArtifactKeyScopeWhere(ctx: { const clauses: Record[] = []; const issueWhere = buildKeyScopeWhere(ctx, "issue"); if (Object.keys(issueWhere).length) clauses.push({ issue: { is: issueWhere } }); + if (key.principalType === "USER") { + const projectWhere = buildKeyScopeWhere(ctx, "project"); + if (Object.keys(projectWhere).length) clauses.push({ project: { is: projectWhere } }); + } if (key.projectIds.length) clauses.push({ projectId: { in: key.projectIds } }); if (key.initiativeIds.length) { clauses.push({ project: { is: { initiativeId: { in: key.initiativeIds } } } }); diff --git a/src/server/services/user-lifecycle.ts b/src/server/services/user-lifecycle.ts index 48859515..f6b0b1c8 100644 --- a/src/server/services/user-lifecycle.ts +++ b/src/server/services/user-lifecycle.ts @@ -1,7 +1,10 @@ import "server-only"; import { + ApiKeyKind, ConnectionStatus, + IntegrationCredentialSource, + IntegrationPrincipalType, InstanceRole, Prisma, type PrismaClient, @@ -475,26 +478,95 @@ async function revokeUserAccess( userId: string, now: Date, reason: string, -): Promise<{ sessions: number; apiKeys: number; connections: number; mappings: number }> { + revokedById: string | null, +): Promise<{ + sessions: number; + apiKeys: number; + connections: number; + mappings: number; + connectionAuthorizations: number; + integrationGrants: number; +}> { const connections = await tx.connection.findMany({ where: { ownerId: userId }, - select: { id: true }, + select: { + id: true, + mappings: { + select: { + id: true, + authorization: { + select: { id: true, credentialSource: true, revokedAt: true }, + }, + }, + }, + }, }); - const connectionIds = connections.map((connection) => connection.id); - const [sessions, apiKeys, actionTokens, mappings] = await Promise.all([ - tx.session.deleteMany({ where: { userId } }), - tx.apiKey.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }), - tx.userActionToken.updateMany({ where: { userId, usedAt: null }, data: { usedAt: now } }), - connectionIds.length - ? tx.connectionMapping.updateMany({ - where: { connectionId: { in: connectionIds } }, - data: { status: "paused" }, - }) - : Promise.resolve({ count: 0 }), - ]); - if (connectionIds.length) { + const personalMappings = connections.flatMap((connection) => + connection.mappings.filter( + (mapping) => + !mapping.authorization || + mapping.authorization.credentialSource === IntegrationCredentialSource.USER_CONNECTION, + ), + ); + const personalMappingIds = personalMappings.map((mapping) => mapping.id); + const personalAuthorizationIds = personalMappings.flatMap((mapping) => + mapping.authorization && !mapping.authorization.revokedAt ? [mapping.authorization.id] : [], + ); + const disconnectConnectionIds = connections + .filter( + (connection) => + !connection.mappings.some( + (mapping) => + mapping.authorization?.credentialSource === + IntegrationCredentialSource.WORKSPACE_GITHUB_APP && !mapping.authorization.revokedAt, + ), + ) + .map((connection) => connection.id); + const [sessions, apiKeys, actionTokens, mappings, authorizations, integrationGrants] = + await Promise.all([ + tx.session.deleteMany({ where: { userId } }), + // userId is the human principal for PERSONAL / SESSION keys, but only + // issuer attribution for AGENT service credentials. Disabling a person + // must not take an independently operated agent or plugin offline. + tx.apiKey.updateMany({ + where: { + userId, + kind: { in: [ApiKeyKind.PERSONAL, ApiKeyKind.SESSION] }, + pluginId: null, + linkedAgentId: null, + revokedAt: null, + }, + data: { revokedAt: now }, + }), + tx.userActionToken.updateMany({ where: { userId, usedAt: null }, data: { usedAt: now } }), + personalMappingIds.length + ? tx.connectionMapping.updateMany({ + where: { id: { in: personalMappingIds } }, + data: { status: "paused" }, + }) + : Promise.resolve({ count: 0 }), + personalAuthorizationIds.length + ? tx.connectionAuthorization.updateMany({ + where: { + id: { in: personalAuthorizationIds }, + credentialSource: IntegrationCredentialSource.USER_CONNECTION, + revokedAt: null, + }, + data: { revokedAt: now, revokedById }, + }) + : Promise.resolve({ count: 0 }), + tx.integrationGrant.updateMany({ + where: { + principalType: IntegrationPrincipalType.USER, + principalUserId: userId, + revokedAt: null, + }, + data: { revokedAt: now, revokedById }, + }), + ]); + if (disconnectConnectionIds.length) { await tx.connection.updateMany({ - where: { id: { in: connectionIds } }, + where: { id: { in: disconnectConnectionIds } }, data: { tokenEnc: null, status: ConnectionStatus.DISCONNECTED, error: reason }, }); } @@ -502,8 +574,10 @@ async function revokeUserAccess( return { sessions: sessions.count, apiKeys: apiKeys.count, - connections: connections.length, + connections: disconnectConnectionIds.length, mappings: mappings.count, + connectionAuthorizations: authorizations.count, + integrationGrants: integrationGrants.count, }; } @@ -540,7 +614,13 @@ export async function suspendUser( data: { status: UserStatus.SUSPENDED, disabledAt: now, authVersion: { increment: 1 } }, select: { id: true, status: true, disabledAt: true, authVersion: true }, }); - const revoked = await revokeUserAccess(tx, user.id, now, "Owner account suspended."); + const revoked = await revokeUserAccess( + tx, + user.id, + now, + "Owner account suspended.", + input.actorId ?? null, + ); await writeInstanceAudit(tx, { ...input, targetUserId: user.id, @@ -584,7 +664,13 @@ export async function softDeleteUser( await assertLifecycleQuorum(tx, input.userId); const now = new Date(); const tombstoneEmail = `deleted+${input.userId}@invalid.local`; - const revoked = await revokeUserAccess(tx, input.userId, now, "Owner account deleted."); + const revoked = await revokeUserAccess( + tx, + input.userId, + now, + "Owner account deleted.", + input.actorId ?? null, + ); await Promise.all([ tx.localCredential.deleteMany({ where: { userId: input.userId } }), From 19a3efb860b19a5dcf6ee30533a788e918aa10b0 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:54:11 -0400 Subject: [PATCH 06/14] feat: enforce restricted project access --- src/app/(app)/w/[slug]/i/[key]/page.tsx | 18 +- src/server/routers/__tests__/issue.test.ts | 18 +- .../routers/__tests__/project-access.test.ts | 269 ++++++++++++++++++ src/server/routers/_app.ts | 4 + src/server/routers/command-palette.ts | 53 +++- src/server/routers/dashboard.ts | 92 +++--- src/server/routers/global.ts | 104 +++++-- src/server/routers/inbox.ts | 79 +++-- src/server/routers/issue.ts | 126 +++++++- src/server/routers/project-access.ts | 167 +++++++++++ src/server/routers/project.ts | 32 ++- src/server/services/project-access.ts | 176 ++++++++++++ 12 files changed, 1007 insertions(+), 131 deletions(-) create mode 100644 src/server/routers/__tests__/project-access.test.ts create mode 100644 src/server/routers/project-access.ts create mode 100644 src/server/services/project-access.ts diff --git a/src/app/(app)/w/[slug]/i/[key]/page.tsx b/src/app/(app)/w/[slug]/i/[key]/page.tsx index 2beabeac..cb83f775 100644 --- a/src/app/(app)/w/[slug]/i/[key]/page.tsx +++ b/src/app/(app)/w/[slug]/i/[key]/page.tsx @@ -1,5 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { db } from "@/server/db"; +import { auth } from "@/server/auth"; +import { issueWhereForViewer } from "@/server/services/project-access"; /** * Short-link route: `/w/{slug}/i/{KEY-NN}` resolves to the corresponding @@ -18,11 +20,20 @@ export default async function IssueByKeyRedirect({ params: Promise<{ slug: string; key: string }>; }) { const { slug, key } = await params; + const session = await auth(); + if (!session?.user?.id) notFound(); const workspace = await db.workspace.findUnique({ where: { slug }, select: { id: true, key: true }, }); if (!workspace) notFound(); + const membership = await db.membership.findUnique({ + where: { + userId_workspaceId: { userId: session.user.id, workspaceId: workspace.id }, + }, + select: { id: true, role: true }, + }); + if (!membership) notFound(); // KEY-NN format: split on the last `-`. Workspace keys are uppercase // alnum (validated server-side), the suffix is the integer. Reject @@ -37,7 +48,12 @@ export default async function IssueByKeyRedirect({ if (wsKey !== workspace.key) notFound(); const issue = await db.issue.findFirst({ - where: { workspaceId: workspace.id, number, deletedAt: null }, + where: { + workspaceId: workspace.id, + number, + deletedAt: null, + AND: [issueWhereForViewer({ workspaceId: workspace.id, membership })], + }, select: { id: true }, }); if (!issue) notFound(); diff --git a/src/server/routers/__tests__/issue.test.ts b/src/server/routers/__tests__/issue.test.ts index 234573f1..465ffd29 100644 --- a/src/server/routers/__tests__/issue.test.ts +++ b/src/server/routers/__tests__/issue.test.ts @@ -1006,7 +1006,7 @@ describe("issueRouter — bulkSetLabels / bulkAssign / bulkAssignAgent", () => { expect(res).toEqual({ updated: 0 }); }); - it("bulk mutations silently ignore issues from other workspaces", async () => { + it("bulk mutations fail atomically when an issue belongs to another workspace", async () => { const { caller, fixture } = await setup(); const otherFixture = await createWorkspaceFixture({ keyPrefix: "OT3" }); fixtures.push(otherFixture); @@ -1017,17 +1017,17 @@ describe("issueRouter — bulkSetLabels / bulkAssign / bulkAssignAgent", () => { const ours = await createIssue(fixture); const theirs = await createIssue(otherFixture); - const res = await caller.bulkSetLabels({ - issueIds: [ours.id, theirs.id], - add: [label.id], - remove: [], - }); - // Only our workspace's issue counts. - expect(res.updated).toBe(1); + await expect( + caller.bulkSetLabels({ + issueIds: [ours.id, theirs.id], + add: [label.id], + remove: [], + }), + ).rejects.toBeTruthy(); const rows = await prisma.issueLabel.findMany({ where: { labelId: label.id }, }); - expect(rows.map((r) => r.issueId)).toEqual([ours.id]); + expect(rows).toHaveLength(0); }); }); diff --git a/src/server/routers/__tests__/project-access.test.ts b/src/server/routers/__tests__/project-access.test.ts new file mode 100644 index 00000000..cb28638f --- /dev/null +++ b/src/server/routers/__tests__/project-access.test.ts @@ -0,0 +1,269 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { ProjectAccessRole, ProjectVisibility, Role } from "@prisma/client"; +import { projectRouter } from "@/server/routers/project"; +import { projectAccessRouter } from "@/server/routers/project-access"; +import { issueRouter } from "@/server/routers/issue"; +import { commandPaletteRouter } from "@/server/routers/command-palette"; +import { dashboardRouter } from "@/server/routers/dashboard"; +import { globalRouter } from "@/server/routers/global"; +import { inboxRouter } from "@/server/routers/inbox"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(disconnectPrisma); + +async function fixture(keyPrefix: string) { + const created = await createWorkspaceFixture({ keyPrefix }); + fixtures.push(created); + return created; +} + +describe("restricted project access", () => { + it("hides restricted projects until an explicit grant exists", async () => { + const setup = await fixture("RPA"); + const owner = projectRouter.createCaller(await buildContext(setup)); + const member = projectRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + const access = projectAccessRouter.createCaller(await buildContext(setup)); + const project = await owner.create({ + key: "LOCK", + name: "Restricted", + visibility: ProjectVisibility.RESTRICTED, + }); + const membership = await getPrisma().membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + }); + + expect((await member.list()).items).toHaveLength(0); + await expect(member.byId({ id: project.id })).rejects.toMatchObject({ code: "NOT_FOUND" }); + + await access.set({ + projectId: project.id, + membershipId: membership.id, + role: ProjectAccessRole.VIEWER, + }); + expect((await member.list()).items.map((row) => row.id)).toEqual([project.id]); + await expect(member.byId({ id: project.id })).resolves.toMatchObject({ id: project.id }); + await expect(member.update({ id: project.id, name: "No" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("lets managers administer a project and audits grant changes", async () => { + const setup = await fixture("RPM"); + const owner = projectRouter.createCaller(await buildContext(setup)); + const ownerAccess = projectAccessRouter.createCaller(await buildContext(setup)); + const member = projectRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + const memberAccess = projectAccessRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + const project = await owner.create({ key: "MGR", name: "Managed" }); + const membership = await getPrisma().membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + }); + await ownerAccess.set({ + projectId: project.id, + membershipId: membership.id, + role: ProjectAccessRole.MANAGER, + }); + + await expect( + member.update({ id: project.id, visibility: ProjectVisibility.RESTRICTED }), + ).resolves.toMatchObject({ visibility: ProjectVisibility.RESTRICTED }); + const candidates = await memberAccess.candidates({ projectId: project.id }); + expect(candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + membershipId: membership.id, + projectRole: ProjectAccessRole.MANAGER, + inheritedAdmin: false, + mutable: true, + }), + expect.objectContaining({ + workspaceRole: Role.OWNER, + inheritedAdmin: true, + mutable: false, + }), + ]), + ); + expect(await memberAccess.list({ projectId: project.id })).toHaveLength(1); + await memberAccess.remove({ projectId: project.id, membershipId: membership.id }); + + const events = await getPrisma().activityEvent.findMany({ + where: { + workspaceId: setup.workspace.id, + subjectType: "project", + subjectId: project.id, + kind: "PROJECT_ACCESS_CHANGED", + }, + }); + expect(events).toHaveLength(2); + await expect(member.byId({ id: project.id })).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("rejects cross-tenant membership ids", async () => { + const setup = await fixture("RPT"); + const other = await fixture("RPO"); + const owner = projectRouter.createCaller(await buildContext(setup)); + const access = projectAccessRouter.createCaller(await buildContext(setup)); + const project = await owner.create({ key: "SAFE", name: "Safe" }); + const foreignMembership = await getPrisma().membership.findFirstOrThrow({ + where: { workspaceId: other.workspace.id, userId: other.secondUser.id }, + }); + + await expect( + access.set({ + projectId: project.id, + membershipId: foreignMembership.id, + role: ProjectAccessRole.VIEWER, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect( + await getPrisma().projectAccess.count({ + where: { workspaceId: setup.workspace.id, projectId: project.id }, + }), + ).toBe(0); + }); + + it("keeps guests out of workspace-visible projects without an explicit grant", async () => { + const setup = await fixture("RPG"); + await getPrisma().membership.update({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + data: { role: Role.GUEST }, + }); + const owner = projectRouter.createCaller(await buildContext(setup)); + const guest = projectRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + await owner.create({ key: "OPEN", name: "Workspace visible" }); + expect((await guest.list()).items).toHaveLength(0); + }); + + it("filters issue lists and applies viewer/contributor roles to direct writes", async () => { + const setup = await fixture("RPI"); + const ownerProjects = projectRouter.createCaller(await buildContext(setup)); + const ownerAccess = projectAccessRouter.createCaller(await buildContext(setup)); + const memberIssues = issueRouter.createCaller( + await buildContext(setup, { asUserId: setup.secondUser.id }), + ); + const project = await ownerProjects.create({ + key: "ISS", + name: "Private issues", + visibility: ProjectVisibility.RESTRICTED, + }); + const issue = await createIssue(setup, { title: "Secret title", projectId: project.id }); + const membership = await getPrisma().membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + }); + + expect((await memberIssues.list({ includeDone: true, limit: 50 })).items).toHaveLength(0); + expect((await memberIssues.count({ includeDone: true, limit: 50 })).count).toBe(0); + await expect(memberIssues.byId({ id: issue.id })).rejects.toMatchObject({ code: "NOT_FOUND" }); + + await ownerAccess.set({ + projectId: project.id, + membershipId: membership.id, + role: ProjectAccessRole.VIEWER, + }); + await expect(memberIssues.byId({ id: issue.id })).resolves.toMatchObject({ id: issue.id }); + await expect(memberIssues.update({ id: issue.id, title: "No" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + + await ownerAccess.set({ + projectId: project.id, + membershipId: membership.id, + role: ProjectAccessRole.CONTRIBUTOR, + }); + await expect(memberIssues.update({ id: issue.id, title: "Allowed" })).resolves.toMatchObject({ + title: "Allowed", + }); + }); + + it("removes restricted work from dashboard, inbox, global work, and search", async () => { + const setup = await fixture("RPS"); + const ownerProjects = projectRouter.createCaller(await buildContext(setup)); + const ownerAccess = projectAccessRouter.createCaller(await buildContext(setup)); + const memberContext = await buildContext(setup, { asUserId: setup.secondUser.id }); + const dashboard = dashboardRouter.createCaller(memberContext); + const inbox = inboxRouter.createCaller(memberContext); + const global = globalRouter.createCaller(memberContext); + const palette = commandPaletteRouter.createCaller(memberContext); + const project = await ownerProjects.create({ + key: "HIDE", + name: "Hidden project", + visibility: ProjectVisibility.RESTRICTED, + }); + const issue = await createIssue(setup, { title: "Needle secret", projectId: project.id }); + await getPrisma().issue.update({ + where: { id: issue.id }, + data: { dueDate: new Date(Date.now() + 86_400_000) }, + }); + await getPrisma().issueAssignee.create({ + data: { issueId: issue.id, userId: setup.secondUser.id }, + }); + const membership = await getPrisma().membership.findUniqueOrThrow({ + where: { + userId_workspaceId: { + userId: setup.secondUser.id, + workspaceId: setup.workspace.id, + }, + }, + }); + + expect((await dashboard.today()).dueSoon).toHaveLength(0); + expect((await inbox.get({ allWorkspaces: false })).assignedUnblocked).toHaveLength(0); + expect(await global.work()).toHaveLength(0); + expect((await global.summary()).openIssues).toBe(0); + expect((await palette.search({ query: "Needle secret" })).issues).toHaveLength(0); + + await ownerAccess.set({ + projectId: project.id, + membershipId: membership.id, + role: ProjectAccessRole.VIEWER, + }); + expect((await dashboard.today()).dueSoon.map((row) => row.id)).toEqual([issue.id]); + expect( + (await inbox.get({ allWorkspaces: false })).assignedUnblocked.map((row) => row.id), + ).toEqual([issue.id]); + expect((await global.work()).map((row) => row.id)).toEqual([issue.id]); + expect((await palette.search({ query: "Needle secret" })).issues.map((row) => row.id)).toEqual([ + issue.id, + ]); + }); +}); diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index a76fcb87..8b684846 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -10,11 +10,13 @@ import { agentCrewRouter, reviewGateRouter } from "./agent-crew"; import { artifactRouter } from "./artifact"; import { canvasRouter } from "./canvas"; import { integrationRouter } from "./integration"; +import { integrationGrantRouter } from "./integration-grant"; import { agentRunRouter } from "./agent-run"; import { chatRouter } from "./chat"; import { aiRouter } from "./ai"; import { workspaceRouter } from "./workspace"; import { projectRouter } from "./project"; +import { projectAccessRouter } from "./project-access"; import { issueRouter } from "./issue"; import { commentRouter } from "./comment"; import { contextSetRouter } from "./context-set"; @@ -97,6 +99,7 @@ export const appRouter = router({ inbox: inboxRouter, initiative: initiativeRouter, integration: integrationRouter, + integrationGrant: integrationGrantRouter, notification: notificationRouter, note: noteRouter, issue: issueRouter, @@ -106,6 +109,7 @@ export const appRouter = router({ commandPalette: commandPaletteRouter, plugin: pluginRouter, project: projectRouter, + projectAccess: projectAccessRouter, projectTemplate: projectTemplateRouter, recurring: recurringRouter, scheduledTask: scheduledTaskRouter, diff --git a/src/server/routers/command-palette.ts b/src/server/routers/command-palette.ts index e6c16f7e..da84b983 100644 --- a/src/server/routers/command-palette.ts +++ b/src/server/routers/command-palette.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { Prisma } from "@prisma/client"; import { router, protectedProcedure } from "@/server/trpc"; import { issueSearchWhere, parseIssueSearch } from "@/server/services/issue-search"; +import { issueWhereForViewer, projectWhereForViewer } from "@/server/services/project-access"; /** * Command palette search — fans out across every entity type the @@ -121,6 +122,21 @@ export const commandPaletteRouter = router({ memberships: { some: { userId } }, ...(input.workspaceId ? { id: input.workspaceId } : {}), }; + const memberships = await ctx.db.membership.findMany({ + where: { + userId, + workspace: { deletedAt: null }, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + }, + select: { id: true, role: true, workspaceId: true }, + }); + const issueAccess = memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); + const projectAccess = memberships.map((membership) => + projectWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); + if (memberships.length === 0) return empty; const parsedIssueSearch = parseIssueSearch(q); @@ -131,7 +147,7 @@ export const commandPaletteRouter = router({ where: { deletedAt: null, workspace: workspaceGate, - AND: [issueSearchWhere(q)!], + AND: [{ OR: issueAccess }, issueSearchWhere(q)!], }, take: input.limit, orderBy: @@ -152,12 +168,17 @@ export const commandPaletteRouter = router({ // ---- Projects --------------------------------------------------- ctx.db.project.findMany({ where: { - deletedAt: null, - archived: false, - workspace: workspaceGate, - OR: [ - { name: { contains: q, mode: "insensitive" } }, - { key: { contains: q, mode: "insensitive" } }, + AND: [ + { OR: projectAccess }, + { + deletedAt: null, + archived: false, + workspace: workspaceGate, + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { key: { contains: q, mode: "insensitive" } }, + ], + }, ], }, take: input.limit, @@ -253,6 +274,7 @@ export const commandPaletteRouter = router({ archivedAt: null, workspace: workspaceGate, title: { contains: q, mode: "insensitive" }, + OR: [{ issueId: null }, { issue: { OR: issueAccess } }], }, take: input.limit, orderBy: { updatedAt: "desc" }, @@ -289,9 +311,20 @@ export const commandPaletteRouter = router({ where: { archivedAt: null, workspace: workspaceGate, - OR: [ - { title: { contains: q, mode: "insensitive" } }, - { description: { contains: q, mode: "insensitive" } }, + AND: [ + { + OR: [ + { projectId: null, issueId: null }, + { project: { OR: projectAccess } }, + { issue: { OR: issueAccess } }, + ], + }, + { + OR: [ + { title: { contains: q, mode: "insensitive" } }, + { description: { contains: q, mode: "insensitive" } }, + ], + }, ], }, take: input.limit, diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 7b4f0910..7bdaaf7c 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -4,6 +4,7 @@ import type { PrismaClient } from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { STALE_RUN_MS } from "@/server/services/agent-presence"; import { presenceAvailability } from "@/lib/transport-display"; +import { issueWhereForViewer } from "@/server/services/project-access"; /** * Dashboard zero-state suggestions. Three buckets — current sprint, @@ -136,6 +137,7 @@ export const dashboardRouter = router({ .query(async ({ ctx, input }) => { const userId = ctx.session.user.id; const limit = input.limit; + const accessWhere = issueWhereForViewer(ctx); // 1. Current sprint slice — unassigned first (so the bucket reads as // "next thing for the team"), then assigned. The active cycle is @@ -146,13 +148,12 @@ export const dashboardRouter = router({ select: { id: true, name: true }, }); - let currentSprintSlice: Array< - Awaited>[number] - > = []; + let currentSprintSlice: Array>[number]> = []; if (activeCycle) { currentSprintSlice = await fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], cycleId: activeCycle.id, status: { category: { notIn: ["DONE", "CANCELED"] } }, }, @@ -168,6 +169,7 @@ export const dashboardRouter = router({ fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], cycleId: activeCycle.id, status: { category: { notIn: ["DONE", "CANCELED"] } }, assignees: { none: {} }, @@ -179,12 +181,10 @@ export const dashboardRouter = router({ fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], cycleId: activeCycle.id, status: { category: { notIn: ["DONE", "CANCELED"] } }, - OR: [ - { assignees: { some: {} } }, - { assignedAgentId: { not: null } }, - ], + OR: [{ assignees: { some: {} } }, { assignedAgentId: { not: null } }], }, orderBy: [{ priority: "desc" }, { createdAt: "desc" }], take: limit, @@ -199,11 +199,13 @@ export const dashboardRouter = router({ const myProjectIds = await myProjectIdSet(ctx.db, { workspaceId: ctx.workspaceId, userId, + accessWhere, }); const unassignedInMyProjects = myProjectIds.size ? await fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], projectId: { in: Array.from(myProjectIds) }, assignees: { none: {} }, assignedAgentId: null, @@ -233,12 +235,9 @@ export const dashboardRouter = router({ select: { stalledThresholdDays: true }, }); let stalled: Array>[number]> = []; - let agentStalled: Array>[number]> = - []; + let agentStalled: Array>[number]> = []; if (ws.stalledThresholdDays > 0) { - const cutoff = new Date( - Date.now() - ws.stalledThresholdDays * 24 * 60 * 60 * 1000, - ); + const cutoff = new Date(Date.now() - ws.stalledThresholdDays * 24 * 60 * 60 * 1000); const now = new Date(); const notSnoozed = { OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }], @@ -246,6 +245,7 @@ export const dashboardRouter = router({ stalled = await fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], updatedAt: { lt: cutoff }, status: { category: { notIn: ["DONE", "CANCELED"] } }, ...notSnoozed, @@ -256,6 +256,7 @@ export const dashboardRouter = router({ agentStalled = await fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], updatedAt: { lt: cutoff }, assignedAgentId: { not: null }, status: { category: { notIn: ["DONE", "CANCELED"] } }, @@ -298,6 +299,7 @@ export const dashboardRouter = router({ */ today: workspaceProcedure.query(async ({ ctx }) => { const now = new Date(); + const accessWhere = issueWhereForViewer(ctx); // 1. Active sprint countdown. const activeCycle = await ctx.db.cycle.findFirst({ @@ -323,6 +325,7 @@ export const dashboardRouter = router({ const dueSoonCutoff = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const dueSoonRows = await ctx.db.issue.findMany({ where: { + AND: [accessWhere], workspaceId: ctx.workspaceId, deletedAt: null, dueDate: { not: null, lte: dueSoonCutoff }, @@ -357,6 +360,7 @@ export const dashboardRouter = router({ // [weekStart, weekEnd) range, grouped by UTC date. const weekIssues = await ctx.db.issue.findMany({ where: { + AND: [accessWhere], workspaceId: ctx.workspaceId, deletedAt: null, dueDate: { gte: weekStart, lt: weekEnd }, @@ -402,6 +406,7 @@ export const dashboardRouter = router({ .default({ limit: 8 }), ) .query(async ({ ctx, input }) => { + const accessWhere = issueWhereForViewer(ctx); const ws = await ctx.db.workspace.findUniqueOrThrow({ where: { id: ctx.workspaceId }, select: { stalledThresholdDays: true }, @@ -409,13 +414,12 @@ export const dashboardRouter = router({ if (ws.stalledThresholdDays <= 0) { return { items: [], stalledThresholdDays: 0 }; } - const cutoff = new Date( - Date.now() - ws.stalledThresholdDays * 24 * 60 * 60 * 1000, - ); + const cutoff = new Date(Date.now() - ws.stalledThresholdDays * 24 * 60 * 60 * 1000); const now = new Date(); const items = await fetchSlice(ctx.db, { workspaceId: ctx.workspaceId, where: { + AND: [accessWhere], updatedAt: { lt: cutoff }, status: { category: "IN_PROGRESS" }, OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }], @@ -444,28 +448,22 @@ export const dashboardRouter = router({ * CARD_FIELDS). Capped small so the child + run pulls stay cheap. */ myWork: workspaceProcedure - .input( - z - .object({ limit: z.number().int().min(1).max(12).default(6) }) - .default({ limit: 6 }), - ) + .input(z.object({ limit: z.number().int().min(1).max(12).default(6) }).default({ limit: 6 })) .query(async ({ ctx, input }) => { const userId = ctx.session.user.id; const limit = input.limit; + const accessWhere = issueWhereForViewer(ctx); const [focusRows, resumeRows] = await Promise.all([ ctx.db.issue.findMany({ where: { + AND: [accessWhere], workspaceId: ctx.workspaceId, deletedAt: null, assignees: { some: { userId } }, status: { category: { notIn: ["DONE", "CANCELED"] } }, }, - orderBy: [ - { priority: "desc" }, - { dueDate: "asc" }, - { updatedAt: "desc" }, - ], + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { updatedAt: "desc" }], take: limit, select: CARD_FIELDS, }), @@ -473,14 +471,13 @@ export const dashboardRouter = router({ workspaceId: ctx.workspaceId, userId, take: limit * 2, + accessWhere, }), ]); const focus = focusRows.map(shapeCard); const focusIds = new Set(focus.map((f) => f.id)); - const resume = resumeRows - .filter((r) => !focusIds.has(r.id)) - .slice(0, limit); + const resume = resumeRows.filter((r) => !focusIds.has(r.id)).slice(0, limit); return { focus, resume }; }), @@ -503,6 +500,7 @@ export const dashboardRouter = router({ * Empty array when no agents exist (the client hides the tile). */ agentActivity: workspaceProcedure.query(async ({ ctx }) => { + const accessWhere = issueWhereForViewer(ctx); const agents = await ctx.db.agent.findMany({ where: { workspaceId: ctx.workspaceId, archivedAt: null }, orderBy: { name: "asc" }, @@ -541,6 +539,7 @@ export const dashboardRouter = router({ // can switch to GROUP BY). const activeRuns = await ctx.db.agentRun.findMany({ where: { + issue: accessWhere, workspaceId: ctx.workspaceId, agentId: { in: agentIds }, status: AgentRunStatus.ACTIVE, @@ -552,10 +551,7 @@ export const dashboardRouter = router({ for (const r of activeRuns) { loadByAgent.set(r.agentId, (loadByAgent.get(r.agentId) ?? 0) + 1); if (r.lastEventAt < runCutoff) { - stalledRunsByAgent.set( - r.agentId, - (stalledRunsByAgent.get(r.agentId) ?? 0) + 1, - ); + stalledRunsByAgent.set(r.agentId, (stalledRunsByAgent.get(r.agentId) ?? 0) + 1); } } @@ -563,22 +559,18 @@ export const dashboardRouter = router({ // workspace setting is enabled. const stalledIssuesByAgent = new Map(); if (ws.stalledThresholdDays > 0) { - const issueCutoff = new Date( - now - ws.stalledThresholdDays * 24 * 60 * 60 * 1000, - ); + const issueCutoff = new Date(now - ws.stalledThresholdDays * 24 * 60 * 60 * 1000); const snoozeNow = new Date(); const stalledIssues = await ctx.db.issue.groupBy({ by: ["assignedAgentId"], where: { + AND: [accessWhere], workspaceId: ctx.workspaceId, deletedAt: null, assignedAgentId: { in: agentIds }, updatedAt: { lt: issueCutoff }, status: { category: { in: ["IN_PROGRESS", "IN_REVIEW"] } }, - OR: [ - { snoozedUntil: null }, - { snoozedUntil: { lte: snoozeNow } }, - ], + OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: snoozeNow } }], }, _count: { _all: true }, }); @@ -606,8 +598,7 @@ export const dashboardRouter = router({ rows.sort((a, b) => { if (a.stalledRuns !== b.stalledRuns) return b.stalledRuns - a.stalledRuns; - if (a.stalledIssues !== b.stalledIssues) - return b.stalledIssues - a.stalledIssues; + if (a.stalledIssues !== b.stalledIssues) return b.stalledIssues - a.stalledIssues; if (a.load !== b.load) return b.load - a.load; return a.name.localeCompare(b.name); }); @@ -644,9 +635,11 @@ async function fetchSlice( function resumeIssueWhere(args: { workspaceId: string; userId: string; + accessWhere: Prisma.IssueWhereInput; }): Prisma.IssueWhereInput { return { workspaceId: args.workspaceId, + AND: [args.accessWhere], deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } }, OR: [ @@ -659,7 +652,12 @@ function resumeIssueWhere(args: { async function fetchResumeCards( db: DB, - args: { workspaceId: string; userId: string; take: number }, + args: { + workspaceId: string; + userId: string; + take: number; + accessWhere: Prisma.IssueWhereInput; + }, ): Promise { if (args.take === 0) return []; @@ -715,19 +713,25 @@ async function fetchResumeCards( */ async function myProjectIdSet( db: DB, - args: { workspaceId: string; userId: string }, + args: { workspaceId: string; userId: string; accessWhere: Prisma.IssueWhereInput }, ): Promise> { const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const [assigned, authored] = await Promise.all([ db.issueAssignee.findMany({ where: { userId: args.userId, - issue: { workspaceId: args.workspaceId, deletedAt: null, projectId: { not: null } }, + issue: { + workspaceId: args.workspaceId, + deletedAt: null, + projectId: { not: null }, + AND: [args.accessWhere], + }, }, select: { issue: { select: { projectId: true } } }, }), db.issue.findMany({ where: { + AND: [args.accessWhere], workspaceId: args.workspaceId, authorId: args.userId, createdAt: { gte: since }, diff --git a/src/server/routers/global.ts b/src/server/routers/global.ts index b74e92ac..9e026527 100644 --- a/src/server/routers/global.ts +++ b/src/server/routers/global.ts @@ -9,6 +9,7 @@ import { hydrateTimelineReferences, mapTimelineRow, } from "@/server/routers/event"; +import { issueWhereForViewer } from "@/server/services/project-access"; /** * Cross-workspace, read-only aggregations for the global "concourse" @@ -19,13 +20,11 @@ import { * (see docs/plans/multiws-restructure.md, Phase 2). */ -/** Workspace ids the caller is a member of (non-deleted). */ -async function memberWorkspaceIds(db: PrismaClient, userId: string): Promise { - const rows = await db.membership.findMany({ +async function memberWorkspaceAccess(db: PrismaClient, userId: string) { + return db.membership.findMany({ where: { userId, workspace: { deletedAt: null } }, - select: { workspaceId: true }, + select: { id: true, role: true, workspaceId: true }, }); - return rows.map((r) => r.workspaceId); } export const globalRouter = router({ @@ -38,6 +37,7 @@ export const globalRouter = router({ const memberships = await ctx.db.membership.findMany({ where: { userId: ctx.session.user.id, workspace: { deletedAt: null } }, select: { + id: true, role: true, workspace: { select: { id: true, slug: true, name: true, key: true, avatarUrl: true } }, }, @@ -47,21 +47,25 @@ export const globalRouter = router({ return Promise.all( memberships.map(async (m) => { const ws = m.workspace; + const issueAccess = issueWhereForViewer({ workspaceId: ws.id, membership: m }); const [members, openIssues, agents, activeRuns, lastEvent] = await Promise.all([ ctx.db.membership.count({ where: { workspaceId: ws.id } }), ctx.db.issue.count({ where: { + AND: [issueAccess], workspaceId: ws.id, deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } }, }, }), ctx.db.agent.count({ where: { workspaceId: ws.id, archivedAt: null } }), - ctx.db.agentRun.count({ where: { workspaceId: ws.id, status: "ACTIVE" } }), - ctx.db.activityEvent.findFirst({ - where: { workspaceId: ws.id }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + ctx.db.agentRun.count({ + where: { workspaceId: ws.id, status: "ACTIVE", issue: issueAccess }, + }), + ctx.db.issue.findFirst({ + where: issueAccess, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, }), ]); return { @@ -75,7 +79,7 @@ export const globalRouter = router({ openIssues, agents, activeRuns, - lastActiveAt: lastEvent?.createdAt ?? null, + lastActiveAt: lastEvent?.updatedAt ?? null, }; }), ); @@ -83,16 +87,23 @@ export const globalRouter = router({ /** Metric tiles for the Mission Control header. */ summary: globalProcedure.query(async ({ ctx }) => { - const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); + const memberships = await memberWorkspaceAccess(ctx.db, ctx.session.user.id); + const wsIds = memberships.map((membership) => membership.workspaceId); + const issueAccess = memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); const [openIssues, activeRuns, onlineAgents, runtimes] = await Promise.all([ ctx.db.issue.count({ where: { + AND: [{ OR: issueAccess }], workspaceId: { in: wsIds }, deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } }, }, }), - ctx.db.agentRun.count({ where: { workspaceId: { in: wsIds }, status: "ACTIVE" } }), + ctx.db.agentRun.count({ + where: { workspaceId: { in: wsIds }, status: "ACTIVE", issue: { OR: issueAccess } }, + }), ctx.db.agent.findMany({ where: { workspaceId: { in: wsIds }, @@ -296,11 +307,16 @@ export const globalRouter = router({ /** Cross-workspace "my work": issues assigned to the caller, newest first. */ work: globalProcedure.query(async ({ ctx }) => { - const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); + const memberships = await memberWorkspaceAccess(ctx.db, ctx.session.user.id); + const wsIds = memberships.map((membership) => membership.workspaceId); + const issueAccess = memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); const assignments = await ctx.db.issueAssignee.findMany({ where: { userId: ctx.session.user.id, issue: { + AND: [{ OR: issueAccess }], workspaceId: { in: wsIds }, deletedAt: null, status: { category: { notIn: ["DONE", "CANCELED"] } }, @@ -364,7 +380,8 @@ export const globalRouter = router({ /** Recent activity across all the caller's workspaces, read-only. */ activity: globalProcedure.query(async ({ ctx }) => { - const wsIds = await memberWorkspaceIds(ctx.db, ctx.session.user.id); + const memberships = await memberWorkspaceAccess(ctx.db, ctx.session.user.id); + const wsIds = memberships.map((membership) => membership.workspaceId); const events = await ctx.db.activityEvent.findMany({ where: { workspaceId: { in: wsIds } }, take: 100, @@ -375,8 +392,61 @@ export const globalRouter = router({ workspace: { select: { id: true, slug: true, name: true, key: true } }, }, }); - const grouped = new Map(); + const payloadIssueId = (payload: unknown): string | null => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const value = (payload as Record).issueId; + return typeof value === "string" ? value : null; + }; + const runIds = events + .filter((event) => event.subjectType === "agent-run") + .map((event) => event.subjectId); + const requestIds = events + .filter((event) => event.subjectType === "action-request") + .map((event) => event.subjectId); + const [runs, requests] = await Promise.all([ + ctx.db.agentRun.findMany({ + where: { id: { in: runIds }, workspaceId: { in: wsIds } }, + select: { id: true, issueId: true }, + }), + ctx.db.actionRequest.findMany({ + where: { id: { in: requestIds }, workspaceId: { in: wsIds } }, + select: { id: true, issueId: true }, + }), + ]); + const runIssue = new Map(runs.map((run) => [run.id, run.issueId])); + const requestIssue = new Map(requests.map((request) => [request.id, request.issueId])); + const eventIssue = new Map(); for (const event of events) { + const issueId = + (event.subjectType === "issue" ? event.subjectId : null) ?? + payloadIssueId(event.payload) ?? + (event.subjectType === "agent-run" ? runIssue.get(event.subjectId) : null) ?? + (event.subjectType === "action-request" ? requestIssue.get(event.subjectId) : null); + if (issueId) eventIssue.set(event.id, issueId); + } + const candidateIssueIds = [...new Set(eventIssue.values())]; + const accessByWorkspace = new Map( + memberships.map((membership) => [ + membership.workspaceId, + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ]), + ); + const visibleIssues = candidateIssueIds.length + ? await ctx.db.issue.findMany({ + where: { + id: { in: candidateIssueIds }, + OR: [...accessByWorkspace.values()], + }, + select: { id: true }, + }) + : []; + const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id)); + const visibleEvents = events.filter((event) => { + const issueId = eventIssue.get(event.id); + return !issueId || visibleIssueIds.has(issueId); + }); + const grouped = new Map(); + for (const event of visibleEvents) { const rows = grouped.get(event.workspaceId) ?? []; rows.push(event); grouped.set(event.workspaceId, rows); @@ -389,7 +459,7 @@ export const globalRouter = router({ ), ), ); - const mapped = events.map((event) => + const mapped = visibleEvents.map((event) => mapTimelineRow(event, refs.get(event.workspaceId)!, event.workspace), ); return collapseRecurringTimelineRows(mapped).slice(0, 40); diff --git a/src/server/routers/inbox.ts b/src/server/routers/inbox.ts index 9d88c013..0e5aefb1 100644 --- a/src/server/routers/inbox.ts +++ b/src/server/routers/inbox.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { CycleStatus, type Prisma, type PrismaClient } from "@prisma/client"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; +import { issueWhereForViewer } from "@/server/services/project-access"; /** * Unified "what's next" inbox. @@ -48,7 +49,11 @@ const VISIT_DEBOUNCE_MS = 5 * 1000; * caller's own handle or email local-part. Cheap heuristic — good enough * until we have a proper mention table. */ -function buildMentionHaystack(name: string | null | undefined, handle: string | null | undefined, email: string): string[] { +function buildMentionHaystack( + name: string | null | undefined, + handle: string | null | undefined, + email: string, +): string[] { const tokens = new Set(); if (handle) tokens.add(handle.toLowerCase()); const emailLocal = email.split("@")[0]; @@ -98,14 +103,16 @@ export const inboxRouter = router({ const mentionTokens = buildMentionHaystack(me.name, me.handle, me.email); const lastVisitAt = me.lastInboxVisitAt; - const workspaceIds = input.allWorkspaces - ? ( - await ctx.db.membership.findMany({ - where: { userId, workspace: { deletedAt: null } }, - select: { workspaceId: true }, - }) - ).map((m) => m.workspaceId) - : [ctx.workspaceId]; + const memberships = input.allWorkspaces + ? await ctx.db.membership.findMany({ + where: { userId, workspace: { deletedAt: null } }, + select: { id: true, role: true, workspaceId: true }, + }) + : [ctx.membership]; + const workspaceIds = memberships.map((membership) => membership.workspaceId); + const issueAccess = memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); if (workspaceIds.length === 0) { return { @@ -157,6 +164,7 @@ export const inboxRouter = router({ const workspaceWhere: Prisma.IssueWhereInput = { workspaceId: { in: workspaceIds }, deletedAt: null, + AND: [{ OR: issueAccess }], }; // ---- 1. Assigned ------------------------------------------------------ @@ -188,6 +196,7 @@ export const inboxRouter = router({ createdAt: { gte: mentionWindow }, // Exclude self-mentions — you aren't notifying yourself. authorId: { not: userId }, + issue: { OR: issueAccess }, }, orderBy: { createdAt: "desc" }, take: 200, @@ -214,9 +223,7 @@ export const inboxRouter = router({ let humanStalled: typeof assigned = []; let agentStalled: typeof assigned = []; if (stalledThresholdDays > 0) { - const stalledCutoff = new Date( - Date.now() - stalledThresholdDays * 24 * 60 * 60 * 1000, - ); + const stalledCutoff = new Date(Date.now() - stalledThresholdDays * 24 * 60 * 60 * 1000); // humanStalled: issues with at least one human assignee that // includes the calling user (back-compat semantic) and which are // older than the threshold. Snoozed rows excluded. @@ -309,7 +316,7 @@ export const inboxRouter = router({ // cross-workspace cycle rollup doesn't really mean anything. let cycleBurn: Awaited> = null; if (!input.allWorkspaces) { - cycleBurn = await buildCycleBurn(ctx.db, ctx.workspaceId); + cycleBurn = await buildCycleBurn(ctx.db, ctx.workspaceId, issueAccess[0]!); } // ---- 6. unreadSinceVisit counts -------------------------------------- @@ -419,12 +426,14 @@ export const inboxRouter = router({ // a chatty workspace doesn't drag the query — Phase 1B can move // this behind a saved view if cardinality grows. const since = new Date(Date.now() - MENTION_WINDOW_MS); + const issueAccess = issueWhereForViewer(ctx); const candidates = await ctx.db.comment.findMany({ where: { workspaceId: ctx.workspaceId, deletedAt: null, createdAt: { gte: since }, authoringAgentId: { not: null }, + issue: issueAccess, }, orderBy: { createdAt: "desc" }, take: 200, @@ -453,10 +462,7 @@ export const inboxRouter = router({ // Filter to mentions of caller, keep at most one entry per issue // (the most recent — Map insertion order from already-DESC // candidates). - const seenIssue = new Map< - string, - (typeof candidates)[number] - >(); + const seenIssue = new Map(); for (const c of candidates) { // Comment.issueId is nullable post-migration 0040 (step // comments). The mentions inbox only surfaces issue-attached @@ -517,10 +523,7 @@ export const inboxRouter = router({ } // Newest-first within the page. - items.sort( - (a, b) => - b.lastComment.createdAt.getTime() - a.lastComment.createdAt.getTime(), - ); + items.sort((a, b) => b.lastComment.createdAt.getTime() - a.lastComment.createdAt.getTime()); return { items }; }), @@ -546,6 +549,7 @@ export const inboxRouter = router({ workspaceId: ctx.workspaceId, assignedUserId: userId, status: "OPEN", + OR: [{ issueId: null }, { issue: issueWhereForViewer(ctx) }], }, orderBy: [{ severity: "desc" }, { createdAt: "desc" }], // Over-fetch (a user's OPEN asks are few) so the per-issue collapse + @@ -603,9 +607,12 @@ export const inboxRouter = router({ const userId = ctx.session.user.id; const memberships = await ctx.db.membership.findMany({ where: { userId, workspace: { deletedAt: null } }, - select: { workspaceId: true }, + select: { id: true, role: true, workspaceId: true }, }); const workspaceIds = memberships.map((m) => m.workspaceId); + const issueAccess = memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ); if (workspaceIds.length === 0) return { count: 0 }; // Use the first workspace's threshold as a reasonable default for @@ -615,9 +622,7 @@ export const inboxRouter = router({ select: { stalledThresholdDays: true }, }); const thresholdDays = ws?.stalledThresholdDays ?? 7; - const stalledCutoff = new Date( - Date.now() - thresholdDays * 24 * 60 * 60 * 1000, - ); + const stalledCutoff = new Date(Date.now() - thresholdDays * 24 * 60 * 60 * 1000); const now = new Date(); const me = await ctx.db.user.findUniqueOrThrow({ where: { id: userId }, @@ -644,6 +649,7 @@ export const inboxRouter = router({ const [assignedCount, stalledCount, candidates, actionRequestCount] = await Promise.all([ ctx.db.issue.count({ where: { + AND: [{ OR: issueAccess }], workspaceId: { in: workspaceIds }, deletedAt: null, assignees: { some: { userId } }, @@ -654,15 +660,14 @@ export const inboxRouter = router({ }), ctx.db.issue.count({ where: { + AND: [{ OR: issueAccess }], workspaceId: { in: workspaceIds }, deletedAt: null, assignees: { some: { userId } }, // Stalled = aged past the threshold; "new" stalled means it // aged in after the last visit (gt lastVisit), so a recent // visit empties this — you've already seen the stale set. - updatedAt: lastVisitAt - ? { lt: stalledCutoff, gt: lastVisitAt } - : { lt: stalledCutoff }, + updatedAt: lastVisitAt ? { lt: stalledCutoff, gt: lastVisitAt } : { lt: stalledCutoff }, status: { category: { notIn: ["DONE", "CANCELED"] } }, ...notSnoozed, }, @@ -671,13 +676,12 @@ export const inboxRouter = router({ where: { workspaceId: { in: workspaceIds }, deletedAt: null, - createdAt: lastVisitAt - ? { gte: mentionCutoff, gt: lastVisitAt } - : { gte: mentionCutoff }, + createdAt: lastVisitAt ? { gte: mentionCutoff, gt: lastVisitAt } : { gte: mentionCutoff }, // Agent comments can carry the API-key owner's authorId. Treat // authoringAgentId as authoritative so those mentions are not // mistaken for self-mentions. OR: [{ authoringAgentId: { not: null } }, { authorId: { not: userId } }], + issue: { OR: issueAccess }, }, select: { body: true }, take: 100, @@ -687,6 +691,7 @@ export const inboxRouter = router({ workspaceId: { in: workspaceIds }, assignedUserId: userId, status: "OPEN", + OR: [{ issueId: null }, { issue: { OR: issueAccess } }], ...(lastVisitAt ? { createdAt: { gt: lastVisitAt } } : {}), }, }), @@ -702,10 +707,7 @@ export const inboxRouter = router({ type DB = PrismaClient; -async function findBlockedIssueIds( - db: DB, - workspaceIds: string[], -): Promise> { +async function findBlockedIssueIds(db: DB, workspaceIds: string[]): Promise> { if (workspaceIds.length === 0) return new Set(); // BLOCKS : from = blocker, to = blocked. // BLOCKED_BY : from = blocked, to = blocker. @@ -739,16 +741,13 @@ async function findBlockedIssueIds( return blocked; } -async function buildCycleBurn( - db: DB, - workspaceId: string, -) { +async function buildCycleBurn(db: DB, workspaceId: string, issueAccess: Prisma.IssueWhereInput) { const cycle = await db.cycle.findFirst({ where: { workspaceId, status: CycleStatus.ACTIVE }, orderBy: { startsAt: "desc" }, include: { issues: { - where: { deletedAt: null }, + where: { deletedAt: null, AND: [issueAccess] }, select: { id: true, status: { select: { category: true } }, diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index dcbbb24a..52d413ec 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -47,6 +47,14 @@ import { } from "@/lib/saved-view-filters"; import type { SlashCommand } from "@/lib/slash-commands"; import type { db as DbHandleType } from "@/server/db"; +import { + assertIssueForViewer, + assertIssuesForViewer, + assertMembersCanReadProject, + assertProjectForViewer, + issueWhereForViewer, +} from "@/server/services/project-access"; +import type { Membership } from "@prisma/client"; const cursorSchema = z.string().optional(); @@ -99,6 +107,7 @@ async function applySlashCommandsToIssue(opts: { commands: SlashCommand[]; ip: string | null; userAgent: string | null; + membership: Pick; }): Promise> { const out: Array<{ kind: string; status: "applied" | "skipped"; reason?: string }> = []; const db = opts.db; @@ -228,6 +237,12 @@ async function applySlashCommandsToIssue(opts: { out.push({ kind: cmd.kind, status: "skipped", reason: "project not found" }); break; } + await assertProjectForViewer( + db, + { workspaceId: opts.workspaceId, membership: opts.membership }, + proj.id, + "CONTRIBUTE", + ); await db.issue.update({ where: { id: opts.issueId }, data: { projectId: proj.id }, @@ -388,13 +403,14 @@ async function buildIssueListWhere( ctx: Parameters[0] & { db: PrismaClient; workspaceId: string; + membership: Pick; }, input: IssueListFilter, ): Promise { const keyWhere = buildKeyScopeWhere(ctx, "issue"); // Compose optional OR clauses under AND so multiple predicates that each // need OR (query, initiativeId=null) don't clobber each other. - const andClauses: Array> = []; + const andClauses: Array> = [issueWhereForViewer(ctx)]; if (input.initiativeId === null || input.withoutInitiative === true) { andClauses.push({ OR: [{ projectId: null }, { project: { initiativeId: null } }], @@ -630,6 +646,7 @@ export const issueRouter = router({ }), ) .query(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "READ", input.includeArchived); const issue = await ctx.db.issue.findFirst({ where: { id: input.id, @@ -846,6 +863,7 @@ export const issueRouter = router({ children: workspaceProcedure .input(z.object({ parentId: z.string().cuid() })) .query(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.parentId, "READ"); const parent = await ctx.db.issue.findFirst({ where: { id: input.parentId, workspaceId: ctx.workspaceId, deletedAt: null }, select: { id: true }, @@ -857,6 +875,7 @@ export const issueRouter = router({ parentId: input.parentId, workspaceId: ctx.workspaceId, deletedAt: null, + AND: [issueWhereForViewer(ctx)], }, orderBy: [{ status: { position: "asc" } }, { number: "asc" }], select: { @@ -889,6 +908,7 @@ export const issueRouter = router({ }), ) .query(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "READ"); // Confirm the issue exists and lives in this tenant before reading // its events — avoids leaking cross-tenant subjectIds through guesses. const issue = await ctx.db.issue.findFirst({ @@ -963,6 +983,7 @@ export const issueRouter = router({ workspaceId: ctx.workspaceId, number, deletedAt: null, + AND: [issueWhereForViewer(ctx)], }, select: { id: true, @@ -1035,6 +1056,19 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + let destinationProjectId = input.projectId ?? null; + if (input.parentId) { + const parent = await assertIssueForViewer(ctx.db, ctx, input.parentId, "CONTRIBUTE"); + destinationProjectId ??= parent.projectId; + } + if (input.projectId) { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "CONTRIBUTE"); + } + await assertMembersCanReadProject(ctx.db, { + workspaceId: ctx.workspaceId, + projectId: destinationProjectId, + userIds: input.assigneeIds, + }); const { applyCommands, ...createInput } = input; const issue = await createIssueWithSideEffects({ db: ctx.db, @@ -1063,6 +1097,7 @@ export const issueRouter = router({ commands: applyCommands, ip: ctx.ip, userAgent: ctx.userAgent, + membership: ctx.membership, }); } // Fire-and-forget AI triage. Skipped server-side when AI is off @@ -1132,6 +1167,17 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const authorizedIssue = await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); + if ( + Object.prototype.hasOwnProperty.call(input, "projectId") && + input.projectId !== authorizedIssue.projectId && + authorizedIssue.projectId + ) { + await assertProjectForViewer(ctx.db, ctx, authorizedIssue.projectId, "MANAGE"); + } + if (input.projectId) { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "CONTRIBUTE"); + } // `mode` is dispatch metadata, not an Issue column — pull it out of // the patch so it never reaches `issue.update`'s data payload. It's // resolved + stamped on the AGENT_ASSIGNED event below. @@ -1463,6 +1509,12 @@ export const issueRouter = router({ assign: workspaceProcedure .input(z.object({ id: z.string().cuid(), userIds: z.array(z.string().cuid()) })) .mutation(async ({ ctx, input }) => { + const authorizedIssue = await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); + await assertMembersCanReadProject(ctx.db, { + workspaceId: ctx.workspaceId, + projectId: authorizedIssue.projectId, + userIds: input.userIds, + }); return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, @@ -1541,6 +1593,7 @@ export const issueRouter = router({ archive: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); try { return await ctx.db.$transaction((tx) => archiveIssue(tx, { @@ -1561,6 +1614,7 @@ export const issueRouter = router({ softDelete: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); try { const result = await ctx.db.$transaction((tx) => archiveIssue(tx, { @@ -1581,6 +1635,7 @@ export const issueRouter = router({ restore: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE", true); try { return await ctx.db.$transaction((tx) => restoreIssue(tx, { @@ -1599,16 +1654,17 @@ export const issueRouter = router({ bulkStatus: workspaceProcedure .input(z.object({ ids: z.array(z.string().cuid()).max(200), statusId: z.string().cuid() })) - .mutation(async ({ ctx, input }) => - ctx.db.issue.updateMany({ + .mutation(async ({ ctx, input }) => { + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE", true); + return ctx.db.issue.updateMany({ where: { id: { in: input.ids }, workspaceId: ctx.workspaceId, deletedAt: null, }, data: { statusId: input.statusId }, - }), - ), + }); + }), /** * Bulk add/remove labels across many issues in one RPC. All referenced @@ -1630,6 +1686,7 @@ export const issueRouter = router({ if (input.issueIds.length === 0) { return { updated: 0, added: 0, removed: 0 }; } + await assertIssuesForViewer(ctx.db, ctx, input.issueIds, "CONTRIBUTE"); const labelIds = Array.from(new Set([...input.add, ...input.remove])); if (labelIds.length > 0) { const found = await ctx.db.label.findMany({ @@ -1736,7 +1793,23 @@ export const issueRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.issueIds.length === 0) return { updated: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.issueIds, "CONTRIBUTE"); + const sourceProjects = await ctx.db.issue.findMany({ + where: { + id: { in: input.issueIds }, + workspaceId: ctx.workspaceId, + projectId: { not: null }, + }, + distinct: ["projectId"], + select: { projectId: true }, + }); + for (const source of sourceProjects) { + if (source.projectId && source.projectId !== input.projectId) { + await assertProjectForViewer(ctx.db, ctx, source.projectId, "MANAGE"); + } + } if (input.projectId) { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "CONTRIBUTE"); const proj = await ctx.db.project.findFirst({ where: { id: input.projectId, workspaceId: ctx.workspaceId }, select: { id: true }, @@ -1793,6 +1866,7 @@ export const issueRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.issueIds.length === 0) return { updated: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.issueIds, "CONTRIBUTE"); if (input.cycleId) { const cyc = await ctx.db.cycle.findFirst({ where: { id: input.cycleId, workspaceId: ctx.workspaceId }, @@ -1852,6 +1926,7 @@ export const issueRouter = router({ if (input.issueIds.length === 0) { return { updated: 0 }; } + await assertIssuesForViewer(ctx.db, ctx, input.issueIds, "CONTRIBUTE"); // Cross-tenant guard: claimedById must be a workspace member when set. if (input.claimedById) { const member = await ctx.db.membership.findFirst({ @@ -1867,6 +1942,18 @@ export const issueRouter = router({ message: "User is not a member of this workspace.", }); } + const projects = await ctx.db.issue.findMany({ + where: { id: { in: input.issueIds }, workspaceId: ctx.workspaceId }, + distinct: ["projectId"], + select: { projectId: true }, + }); + for (const project of projects) { + await assertMembersCanReadProject(ctx.db, { + workspaceId: ctx.workspaceId, + projectId: project.projectId, + userIds: [input.claimedById], + }); + } } return ctx.db.$transaction(async (tx) => { @@ -1937,6 +2024,7 @@ export const issueRouter = router({ if (input.issueIds.length === 0) { return { updated: 0 }; } + await assertIssuesForViewer(ctx.db, ctx, input.issueIds, "CONTRIBUTE"); if (input.assignedAgentId) { const agent = await ctx.db.agent.findFirst({ where: { @@ -2077,6 +2165,7 @@ export const issueRouter = router({ setQueued: workspaceProcedure .input(z.object({ id: z.string().cuid(), queued: z.boolean() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirstOrThrow({ where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, @@ -2123,6 +2212,7 @@ export const issueRouter = router({ release: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); const issue = await ctx.db.issue.findFirstOrThrow({ where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, }); @@ -2148,6 +2238,7 @@ export const issueRouter = router({ queued: true, ...keyWhere, ...(input.includeClaimed ? {} : { claimedAt: null }), + AND: [issueWhereForViewer(ctx)], }, orderBy: [{ claimedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }], take: input.limit, @@ -2202,6 +2293,7 @@ export const issueRouter = router({ if (input.issueId) { await assertKeyScope(ctx, { entity: "issue", id: input.issueId }); + await assertIssueForViewer(ctx.db, ctx, input.issueId, "CONTRIBUTE"); return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirstOrThrow({ where: { @@ -2242,6 +2334,7 @@ export const issueRouter = router({ status: { category: { notIn: ["DONE", "CANCELED"] } }, ...keyWhere, ...(blockedIds.size ? { id: { notIn: [...blockedIds] } } : {}), + AND: [issueWhereForViewer(ctx, "CONTRIBUTE")], }, orderBy: [{ priority: "desc" }, { createdAt: "asc" }], }); @@ -2282,6 +2375,7 @@ export const issueRouter = router({ }), ) .query(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "READ"); const current = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, select: { @@ -2298,11 +2392,12 @@ export const issueRouter = router({ ? { projectId: current.projectId ?? null } : { cycleId: current.cycleId ?? null }; - const baseWhere = { + const baseWhere: Prisma.IssueWhereInput = { workspaceId: ctx.workspaceId, deletedAt: null, ...scopeWhere, - } as const; + AND: [issueWhereForViewer(ctx)], + }; const [prev, next] = await Promise.all([ ctx.db.issue.findFirst({ @@ -2347,6 +2442,7 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); if (input.until.getTime() <= Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", @@ -2394,6 +2490,7 @@ export const issueRouter = router({ unsnooze: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); return ctx.db.$transaction(async (tx) => { const before = await tx.issue.findFirstOrThrow({ where: { @@ -2447,6 +2544,7 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE"); if (input.until && input.until.getTime() <= Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", @@ -2515,6 +2613,7 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.id, "CONTRIBUTE"); return ctx.db.$transaction(async (tx) => { const issue = await tx.issue.findFirstOrThrow({ where: { @@ -2627,6 +2726,7 @@ export const issueRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.ids.length === 0) return { updated: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE"); // Tenant-scope guard for the destination status. const status = await ctx.db.status.findFirst({ where: { id: input.statusId, workspaceId: ctx.workspaceId }, @@ -2738,6 +2838,7 @@ export const issueRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.ids.length === 0) return { updated: 0, added: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE"); const label = await ctx.db.label.findFirst({ where: { id: input.labelId, workspaceId: ctx.workspaceId }, select: { id: true }, @@ -2803,6 +2904,7 @@ export const issueRouter = router({ ) .mutation(async ({ ctx, input }) => { if (input.ids.length === 0) return { updated: 0, removed: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE"); const label = await ctx.db.label.findFirst({ where: { id: input.labelId, workspaceId: ctx.workspaceId }, select: { id: true }, @@ -2868,6 +2970,7 @@ export const issueRouter = router({ .input(z.object({ ids: z.array(z.string().cuid()).max(500) })) .mutation(async ({ ctx, input }) => { if (input.ids.length === 0) return { updated: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE"); try { return await ctx.db.$transaction(async (tx) => { let updated = 0; @@ -2905,6 +3008,7 @@ export const issueRouter = router({ .input(z.object({ ids: z.array(z.string().cuid()).max(500) })) .mutation(async ({ ctx, input }) => { if (input.ids.length === 0) return { updated: 0 }; + await assertIssuesForViewer(ctx.db, ctx, input.ids, "CONTRIBUTE", true); try { return await ctx.db.$transaction(async (tx) => { let updated = 0; @@ -2950,6 +3054,7 @@ export const issueRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "CONTRIBUTE"); const issue = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, select: { id: true }, @@ -2966,6 +3071,7 @@ export const issueRouter = router({ commands: input.commands, ip: ctx.ip, userAgent: ctx.userAgent, + membership: ctx.membership, }); return { results }; }), @@ -2985,6 +3091,7 @@ export const issueRouter = router({ watch: workspaceProcedure .input(z.object({ issueId: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "CONTRIBUTE"); const issue = await ctx.db.issue.findFirst({ where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, select: { id: true }, @@ -3024,6 +3131,7 @@ export const issueRouter = router({ unwatch: workspaceProcedure .input(z.object({ issueId: z.string().cuid() })) .mutation(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "CONTRIBUTE"); const callerAgentId = ctx.apiKey?.linkedAgentId ?? null; if (callerAgentId) { await ctx.db.issueWatcher.deleteMany({ @@ -3045,6 +3153,7 @@ export const issueRouter = router({ watchers: workspaceProcedure .input(z.object({ issueId: z.string().cuid() })) .query(async ({ ctx, input }) => { + await assertIssueForViewer(ctx.db, ctx, input.issueId, "READ"); const rows = await ctx.db.issueWatcher.findMany({ where: { issueId: input.issueId, workspaceId: ctx.workspaceId }, orderBy: { createdAt: "asc" }, @@ -3069,7 +3178,7 @@ export const issueRouter = router({ ? { workspaceId: ctx.workspaceId, agentId: callerAgentId } : { workspaceId: ctx.workspaceId, userId: ctx.session.user.id }; const rows = await ctx.db.issueWatcher.findMany({ - where, + where: { ...where, issue: issueWhereForViewer(ctx) }, orderBy: { createdAt: "desc" }, take: input.limit, include: { @@ -3131,6 +3240,7 @@ export const issueRouter = router({ where: { workspaceId: ctx.workspaceId, userId: ctx.session.user.id, + issue: issueWhereForViewer(ctx), }, select: { issueId: true, diff --git a/src/server/routers/project-access.ts b/src/server/routers/project-access.ts new file mode 100644 index 00000000..0837ee83 --- /dev/null +++ b/src/server/routers/project-access.ts @@ -0,0 +1,167 @@ +import { EventKind, ProjectAccessRole } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { recordChange } from "@/server/audit"; +import { router, workspaceProcedure } from "@/server/trpc"; +import { assertProjectForViewer } from "@/server/services/project-access"; + +const projectInput = z.object({ projectId: z.string().cuid() }); + +export const projectAccessRouter = router({ + candidates: workspaceProcedure.input(projectInput).query(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "MANAGE"); + const memberships = await ctx.db.membership.findMany({ + where: { workspaceId: ctx.workspaceId }, + orderBy: [{ role: "asc" }, { createdAt: "asc" }], + select: { + id: true, + role: true, + user: { select: { id: true, name: true, email: true, image: true, handle: true } }, + projectAccesses: { + where: { projectId: input.projectId }, + select: { role: true }, + take: 1, + }, + }, + }); + return memberships.map(({ projectAccesses, ...membership }) => { + const inheritedAdmin = membership.role === "OWNER" || membership.role === "ADMIN"; + return { + membershipId: membership.id, + workspaceRole: membership.role, + user: membership.user, + projectRole: projectAccesses[0]?.role ?? null, + inheritedAdmin, + mutable: !inheritedAdmin, + }; + }); + }), + + list: workspaceProcedure.input(projectInput).query(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "MANAGE"); + return ctx.db.projectAccess.findMany({ + where: { workspaceId: ctx.workspaceId, projectId: input.projectId }, + orderBy: [{ role: "desc" }, { createdAt: "asc" }], + select: { + id: true, + role: true, + createdAt: true, + updatedAt: true, + membership: { + select: { + id: true, + role: true, + user: { select: { id: true, name: true, email: true, image: true, handle: true } }, + }, + }, + grantedBy: { select: { id: true, name: true, email: true } }, + }, + }); + }), + + set: workspaceProcedure + .input( + projectInput.extend({ + membershipId: z.string().cuid(), + role: z.nativeEnum(ProjectAccessRole), + }), + ) + .mutation(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "MANAGE"); + return ctx.db.$transaction(async (tx) => { + const membership = await tx.membership.findFirst({ + where: { id: input.membershipId, workspaceId: ctx.workspaceId }, + select: { id: true, userId: true, role: true }, + }); + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Workspace member not found." }); + } + if (membership.role === "OWNER" || membership.role === "ADMIN") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace administrators already have full project access.", + }); + } + const before = await tx.projectAccess.findUnique({ + where: { + projectId_membershipId: { + projectId: input.projectId, + membershipId: input.membershipId, + }, + }, + }); + const grant = await tx.projectAccess.upsert({ + where: { + projectId_membershipId: { + projectId: input.projectId, + membershipId: input.membershipId, + }, + }, + create: { + workspaceId: ctx.workspaceId, + projectId: input.projectId, + membershipId: input.membershipId, + role: input.role, + grantedById: ctx.session.user.id, + }, + update: { role: input.role, grantedById: ctx.session.user.id }, + }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "ProjectAccess", + entityId: grant.id, + action: before ? "update" : "create", + before: before ? { membershipId: before.membershipId, role: before.role } : undefined, + after: { membershipId: grant.membershipId, role: grant.role }, + eventKind: EventKind.PROJECT_ACCESS_CHANGED, + subjectType: "project", + subjectId: input.projectId, + payload: { + action: before ? "updated" : "granted", + userId: membership.userId, + role: grant.role, + }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return grant; + }); + }), + + remove: workspaceProcedure + .input(projectInput.extend({ membershipId: z.string().cuid() })) + .mutation(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.projectId, "MANAGE"); + return ctx.db.$transaction(async (tx) => { + const before = await tx.projectAccess.findFirst({ + where: { + workspaceId: ctx.workspaceId, + projectId: input.projectId, + membershipId: input.membershipId, + }, + include: { membership: { select: { userId: true } } }, + }); + if (!before) + throw new TRPCError({ code: "NOT_FOUND", message: "Project grant not found." }); + await tx.projectAccess.delete({ where: { id: before.id } }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "ProjectAccess", + entityId: before.id, + action: "delete", + before: { membershipId: before.membershipId, role: before.role }, + eventKind: EventKind.PROJECT_ACCESS_CHANGED, + subjectType: "project", + subjectId: input.projectId, + payload: { action: "revoked", userId: before.membership.userId, role: before.role }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return { ok: true }; + }); + }), +}); diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 9cc0185e..41d7bd44 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1,9 +1,16 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { CompletionAutomation, EventKind, type StatusCategory } from "@prisma/client"; +import { + CompletionAutomation, + EventKind, + ProjectAccessRole, + ProjectVisibility, + type StatusCategory, +} from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; import { assertWorkspaceAction } from "@/server/services/authorization"; +import { assertProjectForViewer, projectWhereForViewer } from "@/server/services/project-access"; const cursorSchema = z.string().optional(); const projectKey = z @@ -43,7 +50,9 @@ export const projectRouter = router({ ) .query(async ({ ctx, input }) => { const rows = await ctx.db.project.findMany({ - where: { workspaceId: ctx.workspaceId, archived: input.archived, deletedAt: null }, + where: { + AND: [projectWhereForViewer(ctx), { archived: input.archived, deletedAt: null }], + }, take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { updatedAt: "desc" }, @@ -102,6 +111,7 @@ export const projectRouter = router({ byId: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.id, "READ"); const project = await ctx.db.project.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId }, }); @@ -123,6 +133,7 @@ export const projectRouter = router({ summary: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.id, "READ"); const project = await ctx.db.project.findFirst({ where: { id: input.id, @@ -187,6 +198,7 @@ export const projectRouter = router({ overview: workspaceProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { + await assertProjectForViewer(ctx.db, ctx, input.id, "READ"); const project = await ctx.db.project.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, }); @@ -459,6 +471,7 @@ export const projectRouter = router({ repoUrl: projectRepoUrl.optional(), repoBranch: z.string().trim().max(200).optional(), completionAutomation: z.nativeEnum(CompletionAutomation).optional(), + visibility: z.nativeEnum(ProjectVisibility).default(ProjectVisibility.WORKSPACE), }), ) .mutation(async ({ ctx, input }) => { @@ -477,6 +490,17 @@ export const projectRouter = router({ const project = await tx.project.create({ data: { ...input, workspaceId: ctx.workspaceId, createdById: ctx.session.user.id }, }); + if (ctx.membership.role !== "OWNER" && ctx.membership.role !== "ADMIN") { + await tx.projectAccess.create({ + data: { + workspaceId: ctx.workspaceId, + projectId: project.id, + membershipId: ctx.membership.id, + role: ProjectAccessRole.MANAGER, + grantedById: ctx.session.user.id, + }, + }); + } await recordChange(tx, { workspaceId: ctx.workspaceId, actorId: ctx.session.user.id, @@ -513,10 +537,12 @@ export const projectRouter = router({ repoUrl: projectRepoUrl.nullable().optional(), repoBranch: z.string().trim().max(200).nullable().optional(), completionAutomation: z.nativeEnum(CompletionAutomation).nullable().optional(), + visibility: z.nativeEnum(ProjectVisibility).optional(), }), ) .mutation(async ({ ctx, input }) => { assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); + await assertProjectForViewer(ctx.db, ctx, input.id, "MANAGE"); const { id, ...patch } = input; return ctx.db.$transaction(async (tx) => { const before = await tx.project.findFirstOrThrow({ @@ -555,6 +581,7 @@ export const projectRouter = router({ .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); + await assertProjectForViewer(ctx.db, ctx, input.id, "MANAGE"); const project = await ctx.db.project.findFirstOrThrow({ where: { id: input.id, workspaceId: ctx.workspaceId, deletedAt: null }, select: { id: true }, @@ -569,6 +596,7 @@ export const projectRouter = router({ .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { assertWorkspaceAction(ctx.membership.role, "MUTATE_PROJECT"); + await assertProjectForViewer(ctx.db, ctx, input.id, "MANAGE"); const p = await ctx.db.project.findFirstOrThrow({ where: { id: input.id, workspaceId: ctx.workspaceId }, }); diff --git a/src/server/services/project-access.ts b/src/server/services/project-access.ts new file mode 100644 index 00000000..3666bdef --- /dev/null +++ b/src/server/services/project-access.ts @@ -0,0 +1,176 @@ +import "server-only"; + +import type { Membership, Prisma, PrismaClient } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { + assertProjectAction, + buildProjectAccessWhere, + resolveProjectDecision, + type ProjectAction, +} from "@/server/services/authorization"; + +type DbClient = PrismaClient | Prisma.TransactionClient; +type ViewerMembership = Pick; + +export interface ProjectViewer { + workspaceId: string; + membership: ViewerMembership; +} + +export function projectWhereForViewer( + viewer: ProjectViewer, + action: ProjectAction = "READ", +): Prisma.ProjectWhereInput { + return buildProjectAccessWhere({ + workspaceId: viewer.workspaceId, + membershipId: viewer.membership.id, + membershipRole: viewer.membership.role, + action, + }); +} + +/** + * Project-aware issue predicate for human sessions. Unfiled issues retain the + * existing collaborative workspace behavior for members, while guests need a + * concrete project grant and therefore cannot see unfiled work. + */ +export function issueWhereForViewer( + viewer: ProjectViewer, + action: ProjectAction = "READ", +): Prisma.IssueWhereInput { + const project = projectWhereForViewer(viewer, action); + const canUseUnfiled = + viewer.membership.role === "OWNER" || + viewer.membership.role === "ADMIN" || + (viewer.membership.role === "MEMBER" && action !== "MANAGE"); + return { + workspaceId: viewer.workspaceId, + OR: [ + ...(canUseUnfiled ? [{ projectId: null } satisfies Prisma.IssueWhereInput] : []), + { project: project }, + ], + }; +} + +export async function assertProjectForViewer( + db: DbClient, + viewer: ProjectViewer, + projectId: string, + action: ProjectAction, +) { + return assertProjectAction(db as PrismaClient, { + workspaceId: viewer.workspaceId, + membershipId: viewer.membership.id, + membershipRole: viewer.membership.role, + projectId, + action, + }); +} + +export async function assertIssueForViewer( + db: DbClient, + viewer: ProjectViewer, + issueId: string, + action: ProjectAction, + includeArchived = false, +): Promise<{ id: string; projectId: string | null }> { + const issue = await db.issue.findFirst({ + where: { + id: issueId, + workspaceId: viewer.workspaceId, + deletedAt: includeArchived ? undefined : null, + AND: [issueWhereForViewer(viewer, action)], + }, + select: { id: true, projectId: true }, + }); + if (!issue) { + const activeIssue = await db.issue.findFirst({ + where: { id: issueId, workspaceId: viewer.workspaceId, deletedAt: null }, + select: { id: true }, + }); + const notFound = action === "READ" || !activeIssue; + throw new TRPCError({ + code: notFound ? "NOT_FOUND" : "FORBIDDEN", + message: notFound ? "Issue not found." : "You do not have permission to modify this issue.", + }); + } + return issue; +} + +export async function assertIssuesForViewer( + db: DbClient, + viewer: ProjectViewer, + issueIds: readonly string[], + action: ProjectAction, + includeArchived = false, +): Promise { + const ids = [...new Set(issueIds)]; + if (ids.length === 0) return; + const count = await db.issue.count({ + where: { + id: { in: ids }, + workspaceId: viewer.workspaceId, + deletedAt: includeArchived ? undefined : null, + AND: [issueWhereForViewer(viewer, action)], + }, + }); + if (count !== ids.length) { + const activeCount = await db.issue.count({ + where: { + id: { in: ids }, + workspaceId: viewer.workspaceId, + deletedAt: includeArchived ? undefined : null, + }, + }); + const notFound = action === "READ" || activeCount !== ids.length; + throw new TRPCError({ + code: notFound ? "NOT_FOUND" : "FORBIDDEN", + message: notFound + ? "One or more issues were not found." + : "You do not have permission to modify one or more issues.", + }); + } +} + +/** Prevent assignments from becoming an accidental restricted-project grant. */ +export async function assertMembersCanReadProject( + db: DbClient, + params: { workspaceId: string; projectId: string | null; userIds: readonly string[] }, +): Promise { + const userIds = [...new Set(params.userIds)]; + if (userIds.length === 0) return; + const memberships = await db.membership.findMany({ + where: { workspaceId: params.workspaceId, userId: { in: userIds } }, + select: { id: true, userId: true, role: true }, + }); + if (memberships.length !== userIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Every assignee must be a member of this workspace.", + }); + } + for (const membership of memberships) { + if (!params.projectId) { + if (membership.role === "GUEST") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "A guest cannot be assigned unfiled work without project access.", + }); + } + continue; + } + const decision = await resolveProjectDecision(db as PrismaClient, { + workspaceId: params.workspaceId, + membershipId: membership.id, + membershipRole: membership.role, + projectId: params.projectId, + action: "READ", + }); + if (!decision?.allowed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Every assignee must have access to this issue's project.", + }); + } + } +} From 1b7e3d19b2b3ce2335284d28e1bb9bdf9e0fcf60 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 20:56:09 -0400 Subject: [PATCH 07/14] feat: expose scoped integration consent inventory --- .../__tests__/integration-grant.test.ts | 102 +++++++++++++++++- src/server/routers/integration-grant.ts | 79 +++++++++++--- 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/server/routers/__tests__/integration-grant.test.ts b/src/server/routers/__tests__/integration-grant.test.ts index d53ae3d5..98c434a3 100644 --- a/src/server/routers/__tests__/integration-grant.test.ts +++ b/src/server/routers/__tests__/integration-grant.test.ts @@ -1,6 +1,11 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; -import { IntegrationPrincipalType } from "@prisma/client"; import { + IntegrationCapability, + IntegrationGrantScope, + IntegrationPrincipalType, +} from "@prisma/client"; +import { + buildContext, createWorkspaceFixture, disconnectPrisma, getPrisma, @@ -8,6 +13,7 @@ import { } from "@/server/routers/__tests__/helpers"; import { ensureMappingAuthorization } from "@/server/services/github/linkability"; import { assertIntegrationAction } from "@/server/services/integration-authorization"; +import { integrationGrantRouter } from "@/server/routers/integration-grant"; const fixtures: TestFixture[] = []; @@ -47,10 +53,102 @@ async function setup() { mappingId: mapping.id, userId: fixture.user.id, }); - return { db, fixture, mapping }; + return { db, fixture, mapping, connection }; } describe("integration grant enforcement", () => { + it("returns safe principal and project labels in the admin inventory", async () => { + const { db, fixture, mapping } = await setup(); + const authorization = await db.connectionAuthorization.findUniqueOrThrow({ + where: { connectionMappingId: mapping.id }, + }); + const project = await db.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: "PRIVATE", + name: "Private launch", + createdById: fixture.user.id, + visibility: "RESTRICTED", + }, + }); + await db.integrationGrant.create({ + data: { + workspaceId: fixture.workspace.id, + connectionAuthorizationId: authorization.id, + principalType: IntegrationPrincipalType.USER, + principalUserId: fixture.secondUser.id, + scope: IntegrationGrantScope.PROJECT, + projectId: project.id, + capabilities: [IntegrationCapability.READ], + grantedById: fixture.user.id, + }, + }); + + const caller = integrationGrantRouter.createCaller(await buildContext(fixture)); + const rows = await caller.list(); + const row = rows.find((candidate) => candidate.id === authorization.id); + expect(row?.connectionMapping.connection.owner).toMatchObject({ + id: fixture.user.id, + email: fixture.user.email, + }); + expect(row?.grants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + principalUser: expect.objectContaining({ + id: fixture.secondUser.id, + email: fixture.secondUser.email, + }), + project: expect.objectContaining({ + id: project.id, + key: project.key, + name: project.name, + visibility: "RESTRICTED", + }), + }), + ]), + ); + expect(JSON.stringify(rows)).not.toMatch(/hashedKey|tokenEnc|privateKeyEnc/); + }); + + it("lets a member inspect only authorizations for credentials they own", async () => { + const { db, fixture, mapping } = await setup(); + const secondConnection = await db.connection.create({ + data: { + ownerId: fixture.secondUser.id, + provider: "GITHUB", + label: "Member credential", + status: "CONNECTED", + config: { installationId: "182" }, + }, + }); + const secondMapping = await db.connectionMapping.create({ + data: { + workspaceId: fixture.workspace.id, + connectionId: secondConnection.id, + kind: "repo", + target: "acme/member-private", + }, + }); + await ensureMappingAuthorization({ + db, + workspaceId: fixture.workspace.id, + mappingId: secondMapping.id, + userId: fixture.secondUser.id, + }); + + const memberCaller = integrationGrantRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + const owned = await memberCaller.listOwned(); + expect(owned.map((row) => row.connectionMappingId)).toEqual([secondMapping.id]); + expect(owned[0]?.connectionMapping.connection.ownerId).toBe(fixture.secondUser.id); + await expect(memberCaller.list()).rejects.toMatchObject({ code: "FORBIDDEN" }); + + const ownerCaller = integrationGrantRouter.createCaller(await buildContext(fixture)); + const ownerRows = await ownerCaller.listOwned(); + expect(ownerRows.map((row) => row.connectionMappingId)).toEqual([mapping.id]); + }); + it("requires owner authorization and an exact principal grant even for an owner", async () => { const { db, fixture, mapping } = await setup(); await expect( diff --git a/src/server/routers/integration-grant.ts b/src/server/routers/integration-grant.ts index 5f761d65..4e380586 100644 --- a/src/server/routers/integration-grant.ts +++ b/src/server/routers/integration-grant.ts @@ -7,6 +7,7 @@ import { IntegrationGrantScope, IntegrationPrincipalType, Role, + type Prisma, } from "@prisma/client"; import { TRPCError } from "@trpc/server"; import { adminProcedure, router, workspaceProcedure } from "@/server/trpc"; @@ -28,26 +29,74 @@ function isSubset(values: IntegrationCapability[], ceiling: IntegrationCapabilit return values.every((value) => allowed.has(value)); } +const authorizationManagementInclude = { + connectionMapping: { + select: { + id: true, + kind: true, + target: true, + direction: true, + status: true, + connection: { + select: { + id: true, + provider: true, + label: true, + account: true, + ownerId: true, + owner: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + }, + }, + githubApp: { select: { id: true, name: true, installationId: true } }, + authorizedBy: { select: { id: true, name: true, email: true, image: true } }, + revokedBy: { select: { id: true, name: true, email: true, image: true } }, + grants: { + orderBy: { createdAt: "asc" }, + include: { + principalUser: { select: { id: true, name: true, email: true, image: true } }, + principalAgent: { select: { id: true, name: true, profileKey: true, avatar: true } }, + principalApiKey: { + select: { + id: true, + name: true, + prefix: true, + kind: true, + revokedAt: true, + expiresAt: true, + }, + }, + project: { select: { id: true, key: true, name: true, visibility: true } }, + grantedBy: { select: { id: true, name: true, email: true } }, + revokedBy: { select: { id: true, name: true, email: true } }, + }, + }, +} satisfies Prisma.ConnectionAuthorizationInclude; + export const integrationGrantRouter = router({ list: adminProcedure.query(({ ctx }) => ctx.db.connectionAuthorization.findMany({ where: { workspaceId: ctx.workspaceId }, - include: { - connectionMapping: { - select: { - id: true, - kind: true, - target: true, - direction: true, - status: true, - connection: { - select: { id: true, provider: true, label: true, account: true, ownerId: true }, - }, - }, - }, - githubApp: { select: { id: true, name: true, installationId: true } }, - grants: true, + include: authorizationManagementInclude, + orderBy: { createdAt: "asc" }, + }), + ), + + /** + * A credential owner may inspect consent and every grant derived from their + * personal connection without gaining workspace-wide integration inventory. + * Workspace App credentials remain admin-managed even when their legacy + * synthesized Connection row records a creating user. + */ + listOwned: workspaceProcedure.query(({ ctx }) => + ctx.db.connectionAuthorization.findMany({ + where: { + workspaceId: ctx.workspaceId, + credentialSource: IntegrationCredentialSource.USER_CONNECTION, + connectionMapping: { connection: { ownerId: ctx.session.user.id } }, }, + include: authorizationManagementInclude, orderBy: { createdAt: "asc" }, }), ), From 957ff9fa08a445ce09d58b25ee3675b033d1c457 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Tue, 25 Aug 2026 21:08:27 -0400 Subject: [PATCH 08/14] feat: add project and integration access management UI --- .../w/[slug]/projects/[id]/access/page.tsx | 398 ++++++++++++ src/app/(app)/w/[slug]/projects/[id]/page.tsx | 11 + src/app/(app)/w/[slug]/projects/page.tsx | 7 +- .../w/[slug]/settings/connections/page.tsx | 391 +++++------ src/components/project-chip.tsx | 21 +- .../settings/integration-access-panel.tsx | 606 ++++++++++++++++++ tests/e2e/project-access-management.spec.ts | 77 +++ 7 files changed, 1286 insertions(+), 225 deletions(-) create mode 100644 src/app/(app)/w/[slug]/projects/[id]/access/page.tsx create mode 100644 src/components/settings/integration-access-panel.tsx create mode 100644 tests/e2e/project-access-management.spec.ts diff --git a/src/app/(app)/w/[slug]/projects/[id]/access/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/access/page.tsx new file mode 100644 index 00000000..5b1d269f --- /dev/null +++ b/src/app/(app)/w/[slug]/projects/[id]/access/page.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { use, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { LockKeyhole, ShieldCheck, UserPlus, Users } from "lucide-react"; +import { ProjectAccessRole, ProjectVisibility } from "@prisma/client"; +import { Topbar } from "@/components/topbar"; +import { Avatar } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Confirm, QuickForm } from "@/components/ui/modal"; +import { EmptyState, Section, Skeleton } from "@/components/ui"; +import { Card } from "@/components/settings/card"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { trpc } from "@/lib/trpc"; + +const ROLE_COPY: Record = { + VIEWER: { + label: "Viewer", + description: "View this project and its issues, comments, artifacts, and activity.", + }, + CONTRIBUTOR: { + label: "Contributor", + description: "View and create or update work. Cannot manage access or project settings.", + }, + MANAGER: { + label: "Manager", + description: + "Contributor access plus project settings, access, integrations, archive, and delete.", + }, +}; + +const ROLE_OPTIONS = Object.values(ProjectAccessRole).map((role) => ({ + value: role, + label: ROLE_COPY[role].label, +})); + +export default function ProjectAccessPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const workspace = useWorkspace(); + const utils = trpc.useUtils(); + const projectQuery = trpc.project.byId.useQuery({ id }); + const accessQuery = trpc.projectAccess.list.useQuery({ projectId: id }); + const candidateQuery = trpc.projectAccess.candidates.useQuery({ projectId: id }); + const [visibilityTarget, setVisibilityTarget] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [membershipId, setMembershipId] = useState(""); + const [role, setRole] = useState(ProjectAccessRole.VIEWER); + const [removeTarget, setRemoveTarget] = useState<{ + membershipId: string; + name: string; + fallback: boolean; + } | null>(null); + + const invalidate = async () => { + await Promise.all([ + utils.project.byId.invalidate({ id }), + utils.project.list.invalidate(), + utils.projectAccess.list.invalidate({ projectId: id }), + utils.projectAccess.candidates.invalidate({ projectId: id }), + ]); + }; + + const updateProject = trpc.project.update.useMutation({ + onSuccess: invalidate, + }); + const setAccess = trpc.projectAccess.set.useMutation({ + onSuccess: async () => { + await invalidate(); + setAddOpen(false); + setMembershipId(""); + setRole(ProjectAccessRole.VIEWER); + }, + }); + const removeAccess = trpc.projectAccess.remove.useMutation({ onSuccess: invalidate }); + + const directMembershipIds = useMemo( + () => new Set((accessQuery.data ?? []).map((grant) => grant.membership.id)), + [accessQuery.data], + ); + const candidates = useMemo( + () => + (candidateQuery.data ?? []).filter( + (member) => member.mutable && !directMembershipIds.has(member.membershipId), + ), + [candidateQuery.data, directMembershipIds], + ); + const peopleLosingInheritedAccess = useMemo( + () => + (candidateQuery.data ?? []).filter( + (member) => + member.workspaceRole === "MEMBER" && !directMembershipIds.has(member.membershipId), + ).length, + [candidateQuery.data, directMembershipIds], + ); + + const project = projectQuery.data; + if (projectQuery.error || accessQuery.error) { + return ( +
+ } + title="Project unavailable" + description="It may have been removed or your access may have changed." + action={ + + } + /> +
+ ); + } + if (!project || accessQuery.isLoading) { + return ( +
+ + + +
+ ); + } + + const isRestricted = project.visibility === ProjectVisibility.RESTRICTED; + + return ( + <> + + + + } + /> +
+
+
+
+ isRestricted && setVisibilityTarget(ProjectVisibility.WORKSPACE)} + /> + !isRestricted && setVisibilityTarget(ProjectVisibility.RESTRICTED)} + /> +
+
+ +
setAddOpen(true)}> + Add person + + } + > + +
+ + + +
+
Workspace owners and admins
+

+ Workspace admin · always has full project access +

+
+ inherited +
+ + {(accessQuery.data ?? []).map((grant) => { + const person = grant.membership.user; + const displayName = person.name || person.email; + return ( +
+
+ +
+
{displayName}
+
+ {person.name ? person.email : grant.membership.role.toLowerCase()} +
+
+
+
+ + value && + setAccess.mutate({ + projectId: id, + membershipId: grant.membership.id, + role: value as ProjectAccessRole, + }) + } + className="min-w-36 flex-1 sm:flex-none" + /> + +
+
+ ); + })} + + {(accessQuery.data ?? []).length === 0 && ( + } + title={isRestricted ? "No direct project roles" : "No additional project roles"} + description={ + isRestricted + ? "Only workspace admins can access this project. Add a person before sharing it." + : "Workspace members inherit contributor access. Guests still need a direct role." + } + action={ + + } + /> + )} +
+
+
+
+ + { + if (!membershipId) return { error: "Choose a workspace member." }; + try { + await setAccess.mutateAsync({ projectId: id, membershipId, role }); + } catch (error) { + return { error: error instanceof Error ? error.message : "Could not grant access." }; + } + }} + > + + setMembershipId(value ?? "")} + options={candidates.map((member) => ({ + value: member.membershipId, + label: `${member.user.name || member.user.email} · ${member.workspaceRole.toLowerCase()}`, + }))} + /> + + + value && setRole(value as ProjectAccessRole)} + /> + + + + !open && setVisibilityTarget(null)} + title={ + visibilityTarget === ProjectVisibility.RESTRICTED + ? `Restrict ${project.name}?` + : `Make ${project.name} workspace-visible?` + } + description={ + visibilityTarget === ProjectVisibility.RESTRICTED ? ( + <> + Members without a direct role will lose access. Based on the current roster, this + affects {peopleLosingInheritedAccess}{" "} + {peopleLosingInheritedAccess === 1 ? "person" : "people"}. Existing direct roles + remain. + + ) : ( + "All workspace members gain view and contributor access. Guests still need a direct role, and existing managers remain managers." + ) + } + primaryLabel={ + visibilityTarget === ProjectVisibility.RESTRICTED + ? "Restrict project" + : "Make workspace-visible" + } + loading={updateProject.isPending} + onConfirm={async () => { + if (!visibilityTarget) return; + await updateProject.mutateAsync({ id, visibility: visibilityTarget }); + setVisibilityTarget(null); + }} + /> + + !open && setRemoveTarget(null)} + title={`Remove ${removeTarget?.name ?? "this person"}?`} + description={ + removeTarget?.fallback + ? "Their direct role is removed. They fall back to workspace member access." + : "Their direct role is removed and they will lose access to this restricted project." + } + primaryLabel="Remove access" + loading={removeAccess.isPending} + onConfirm={async () => { + if (!removeTarget) return; + await removeAccess.mutateAsync({ + projectId: id, + membershipId: removeTarget.membershipId, + }); + setRemoveTarget(null); + }} + /> + + ); +} + +function VisibilityChoice({ + checked, + title, + description, + onSelect, +}: { + checked: boolean; + title: string; + description: string; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx index 4189cd61..a5f74e7e 100644 --- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx @@ -11,6 +11,7 @@ import { FilePlus, Inbox, Link2, + LockKeyhole, MessageCircle, UserCheck, } from "lucide-react"; @@ -138,6 +139,11 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st /> {project.icon && {project.icon}} {project.name} + {project.visibility === "RESTRICTED" && ( + + Restricted + + )} } subtitle={project.key} @@ -155,6 +161,11 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st + + +