diff --git a/DEVLOG.md b/DEVLOG.md index 2f43aae4..78879629 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,36 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-08-25 — Restricted projects and explicit integration grants + +- Added workspace-visible and restricted projects with explicit Viewer, + Contributor, and Manager grants tied to tenant memberships. Project and issue + authorization now applies consistently to direct reads, mutations, bulk + operations, search, dashboards, Command Center, derived views, resources, + analytics, notifications, and live/catch-up realtime delivery. +- Added credential-owner `ConnectionAuthorization` records and typed + `IntegrationGrant` principals for users, agents, API keys, and workspace + automation. Effective GitHub/integration access is the intersection of + project access, owner consent, capability grants, mapping state/direction, + key narrowing, and exact provider authority. +- Added live PERSONAL/SESSION key revalidation for user state, workspace + membership, role demotion, and project grants while preserving AGENT and + PLUGIN keys as independent service principals. Membership/user lifecycle + changes revoke human keys and grants without deleting their audit history. +- Added Project Access and Integration Access management surfaces with + restricted indicators, impact confirmations, owner-scoped consent views, + safe principal metadata, responsive layouts, and accessible controls. +- Added compatibility-first tenant-safe migrations plus role, cross-tenant, + direct-ID, revocation, realtime, notification, resource, GitHub, API-key, + and management-flow coverage. Focused agent-owned suites and TypeScript + checks passed; the coordinated local and exact-head CI gates are recorded on + the implementation pull request. +- Final authorization review aligned every issue-derived surface on the same + GUEST/unfiled policy, kept project/initiative/label API-key narrowing as an + integration ceiling after defaults are resolved, and now revokes derived + grants when credential source, exact app binding, or capability ceilings + change. + ## 2026-08-25 — Canonical local and external user identity foundation - Added a provider-neutral instance authentication policy for local-only, diff --git a/docs/engineering/authorization.md b/docs/engineering/authorization.md new file mode 100644 index 00000000..c65882a1 --- /dev/null +++ b/docs/engineering/authorization.md @@ -0,0 +1,84 @@ +# Authorization boundaries + +Forge keeps authentication, workspace membership, project access, API-key +narrowing, and external-credential authority as separate layers. A successful +login never grants workspace, project, or integration access by itself. + +## Project access + +Projects are either `WORKSPACE` or `RESTRICTED`. + +- Workspace owners and admins inherit full project access. +- Members inherit read and contribute access only for `WORKSPACE` projects. +- Guests do not inherit project access. +- Explicit grants are `VIEWER`, `CONTRIBUTOR`, or `MANAGER` and belong to a + workspace membership. A `MANAGER` may maintain that project's access list. + +Collection queries must compose the shared project/issue visibility predicate. +Direct reads return `NOT_FOUND` when the caller cannot read the underlying +project, so restricted resource existence is not disclosed. Mutations assert +the required action at the destination and, for moves, at the source as well. +Assignment, authorship, watching, an artifact grant, or a canvas reference does +not widen project access. + +The same floor applies to comments, relations, files, artifacts, plans, runs, +goals, canvases, context sets, analytics, activity, notifications, realtime +events, search, dashboards, Command Center, standups, and Today views. Durable +workers may operate across a workspace, but human-facing fanout and hydration +must be filtered for the recipient. + +## Integration access + +External access is deliberately two-stage: + +1. `ConnectionAuthorization` records credential-owner consent for one mapping, + its exact credential source, capability ceiling, security-policy digest, + and revocation state. +2. `IntegrationGrant` authorizes an exact user, agent, API key, or workspace + automation principal for a workspace or project and an explicit capability + set. + +The effective permission is the intersection of current workspace membership, +project access, an active principal grant, active credential-owner consent, +mapping state and direction, API-key scope/narrowing, and provider or GitHub App +authority. Workspace administration does not bypass credential consent. +Changing security-sensitive mapping fields invalidates the authorization +digest; disconnecting or removing a personal credential pauses its mappings +and revokes consent. Workspace-App authority is kept separate from the human +who originally configured it. Switching credential source or exact App binding, +or shrinking the authorized capability ceiling, revokes every derived grant so +principals must be reviewed against the new consent boundary. + +Capabilities are explicit: `READ`, `IMPORT`, `LINK`, `SYNC`, `WRITE`, and +`ADMIN`. Callers must hold every capability required by an operation; `ADMIN` +does not imply provider data permissions. + +## API keys and revocation + +Personal and session keys act as the current human user. Every request +revalidates the user's active state, workspace membership, current role, and +project grants. Stored project, label, and initiative restrictions remain an +additional ceiling and can never widen access. Role demotion immediately +removes effective `ADMIN` authority. + +Agent and plugin keys are service principals. Their issuer is audit +attribution, not inherited authorization, so suspending an issuer does not +silently disable unrelated automation. Integration use still requires a grant +for the exact service principal. + +Membership removal and user suspension/deletion revoke human keys and user +integration grants immediately. Revoked grant rows are retained for audit; +they are not cascade-deleted with membership removal. + +## Implementation rules + +- Every authorization row and foreign-key path is tenant-scoped by + `workspaceId`. +- Use the central project and integration authorization services; do not + reproduce role checks in individual routers. +- Filter list, count, aggregate, notification, hydration, and realtime paths as + well as direct reads and writes. +- Write audit/activity records in the same transaction as grant, consent, and + revocation changes. +- Never return credential secrets, token material, key hashes, or unauthorized + principal metadata from management queries. 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..ca2fb06c --- /dev/null +++ b/prisma/migrations/20260825190000_project_integration_authorization/migration.sql @@ -0,0 +1,273 @@ +-- 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_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..51863007 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,12 @@ 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") @@unique([userId, workspaceId]) + @@unique([id, workspaceId]) @@index([workspaceId, role]) } @@ -1517,6 +1572,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 +1597,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 +2346,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 +2703,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 +2808,88 @@ 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) + 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 +3313,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/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/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 + + +