diff --git a/app/drizzle/migrations/0008_lumpy_jubilee.sql b/app/drizzle/migrations/0008_lumpy_jubilee.sql new file mode 100644 index 00000000..f8e8a888 --- /dev/null +++ b/app/drizzle/migrations/0008_lumpy_jubilee.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."connection_visibility" AS ENUM('private', 'shared');--> statement-breakpoint +ALTER TABLE "connection" ADD COLUMN "visibility" "connection_visibility" DEFAULT 'private' NOT NULL; \ No newline at end of file diff --git a/app/drizzle/migrations/meta/0008_snapshot.json b/app/drizzle/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000..69ac01e5 --- /dev/null +++ b/app/drizzle/migrations/meta/0008_snapshot.json @@ -0,0 +1,982 @@ +{ + "id": "5ec3aba2-98b3-4832-a130-965cbfd71d54", + "prevId": "34edd0e6-71f6-4597-ae67-e79f8365a833", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_userId_user_id_fk": { + "name": "api_key_userId_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_user_id_user_id_fk": { + "name": "audit_log_user_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "connection_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configEncrypted": { + "name": "configEncrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_per_card_db": { + "name": "allow_per_card_db", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "visibility": { + "name": "visibility", + "type": "connection_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "connection_userId_user_id_fk": { + "name": "connection_userId_user_id_fk", + "tableFrom": "connection", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_share": { + "name": "dashboard_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "dashboardId": { + "name": "dashboardId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "role": { + "name": "role", + "type": "share_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_share_dashboardId_dashboard_id_fk": { + "name": "dashboard_share_dashboardId_dashboard_id_fk", + "tableFrom": "dashboard_share", + "tableTo": "dashboard", + "columnsFrom": ["dashboardId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_share_userId_user_id_fk": { + "name": "dashboard_share_userId_user_id_fk", + "tableFrom": "dashboard_share", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layoutJson": { + "name": "layoutJson", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"version\":2,\"pages\":[{\"id\":\"page-1\",\"title\":\"Page 1\",\"widgets\":[],\"gridLayout\":[]}]}'::jsonb" + }, + "thumbnailJson": { + "name": "thumbnailJson", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_userId_user_id_fk": { + "name": "dashboard_userId_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_updated_by_user_id_fk": { + "name": "dashboard_updated_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "sso_protocol", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'oidc'" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_encrypted": { + "name": "client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "claim_mappings": { + "name": "claim_mappings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auto_provision": { + "name": "auto_provision", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_role": { + "name": "default_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'creator'" + }, + "enforce_sso": { + "name": "enforce_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_provider_tenant_issuer_unique": { + "name": "sso_provider_tenant_issuer_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "issuer"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'creator'" + }, + "can_write": { + "name": "can_write", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "force_password_change": { + "name": "force_password_change", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "passwordChangedAt": { + "name": "passwordChangedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disabledAt": { + "name": "disabledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastLoginAt": { + "name": "lastLoginAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_tenant_unique": { + "name": "user_email_tenant_unique", + "nullsNotDistinct": false, + "columns": ["email", "tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.widget_template": { + "name": "widget_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "chartType": { + "name": "chartType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectorType": { + "name": "connectorType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "previewImageUrl": { + "name": "previewImageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "widget_template_createdBy_user_id_fk": { + "name": "widget_template_createdBy_user_id_fk", + "tableFrom": "widget_template", + "tableTo": "user", + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_type": { + "name": "connection_type", + "schema": "public", + "values": ["neo4j", "postgresql"] + }, + "public.connection_visibility": { + "name": "connection_visibility", + "schema": "public", + "values": ["private", "shared"] + }, + "public.share_role": { + "name": "share_role", + "schema": "public", + "values": ["viewer", "editor"] + }, + "public.sso_protocol": { + "name": "sso_protocol", + "schema": "public", + "values": ["oidc"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["admin", "creator", "reader"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/app/drizzle/migrations/meta/_journal.json b/app/drizzle/migrations/meta/_journal.json index 78d959df..f469b963 100644 --- a/app/drizzle/migrations/meta/_journal.json +++ b/app/drizzle/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1778862299752, "tag": "0007_free_loners", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1781144139217, + "tag": "0008_lumpy_jubilee", + "breakpoints": true } ] } diff --git a/app/e2e/connection-sharing.spec.ts b/app/e2e/connection-sharing.spec.ts new file mode 100644 index 00000000..b1b6736f --- /dev/null +++ b/app/e2e/connection-sharing.spec.ts @@ -0,0 +1,105 @@ +import { test, expect, ALICE, BOB } from "./fixtures"; + +/** + * Connection visibility model (#901) — "admin provisions, all use". + * + * ALICE (admin) owns the seeded connections; BOB (creator) starts with no + * connections of his own. Sharing a connection makes it queryable and + * visible tenant-wide, read-only for non-owners; making it private again + * drops BOB back to the dashboard-bound fallback (#972). + * + * Serial: the tests walk one share/unshare lifecycle on conn-pg-001 and + * restore the private state at the end so other specs see the seed state. + */ +test.describe.serial("Connection sharing (#901)", () => { + const PG_CONNECTION_ID = "conn-pg-001"; + const PG_CONNECTION_NAME = "Movies DB (PostgreSQL)"; + + test("creator does not see another user's private connection", async ({ + authPage, + page, + }) => { + await authPage.login(BOB.email, BOB.password); + await page.goto("/connections"); + await page.waitForLoadState("networkidle"); + await expect(page.getByText(PG_CONNECTION_NAME)).not.toBeVisible(); + }); + + test("admin shares a connection with the workspace", async ({ + authPage, + page, + }) => { + await authPage.login(ALICE.email, ALICE.password); + await page.goto("/connections"); + // Scope to the single card element (established pattern from + // connections.spec — bare div filters match every ancestor). + const card = page + .locator("div[class*='border']") + .filter({ hasText: PG_CONNECTION_NAME }) + .filter({ + has: page.getByRole("button", { name: "Connection actions" }), + }); + await card.getByRole("button", { name: "Connection actions" }).click(); + await page.getByRole("menuitem", { name: "Share with workspace" }).click(); + await expect(card.getByText("Shared", { exact: true })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("creator sees the shared connection read-only and can query it", async ({ + authPage, + page, + }) => { + await authPage.login(BOB.email, BOB.password); + await page.goto("/connections"); + await expect(page.getByText(PG_CONNECTION_NAME)).toBeVisible(); + await expect( + page.getByText("Shared", { exact: true }).first(), + ).toBeVisible(); + // No management menu at all for non-owners: every action is gated. + // (BOB owns nothing, so no card on the page has an actions menu.) + await expect( + page.getByRole("button", { name: "Connection actions" }), + ).not.toBeVisible(); + + // Direct query through the shared connection — the #901 fast path. + const res = await page.request.post("/api/query", { + data: { connectionId: PG_CONNECTION_ID, query: "SELECT 1 AS ok" }, + }); + expect(res.status()).toBe(200); + }); + + test("creator cannot change visibility (admin-only)", async ({ + authPage, + page, + }) => { + await authPage.login(BOB.email, BOB.password); + const res = await page.request.patch( + `/api/connections/${PG_CONNECTION_ID}`, + { data: { visibility: "private" } }, + ); + expect(res.status()).toBe(403); + }); + + test("admin makes it private again; creator loses direct access", async ({ + authPage, + page, + }) => { + await authPage.login(ALICE.email, ALICE.password); + const patch = await page.request.patch( + `/api/connections/${PG_CONNECTION_ID}`, + { data: { visibility: "private" } }, + ); + expect(patch.ok()).toBeTruthy(); + + await authPage.logout(); + await authPage.login(BOB.email, BOB.password); + // Arbitrary direct queries now fall back to dashboard-bound access: + // public dashboards reference this connection, but "SELECT 1 AS ok" + // is not one of their saved queries -> 403 (#972). + const res = await page.request.post("/api/query", { + data: { connectionId: PG_CONNECTION_ID, query: "SELECT 1 AS ok" }, + }); + expect(res.status()).toBe(403); + }); +}); diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index 6e3b9572..413689cd 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import { useSession } from "next-auth/react"; import { Database, Plus, ChevronDown } from "lucide-react"; import { Neo4jLogo, PostgreSQLLogo } from "@/components/db-logos"; import { @@ -67,6 +68,8 @@ const DEFAULT_FORM = { }; export default function ConnectionsPage() { + const { data: session } = useSession(); + const isAdmin = session?.user?.role === "admin"; const { data: connections, isLoading } = useConnections(); const createConnection = useCreateConnection(); const updateConnection = useUpdateConnection(); @@ -1138,10 +1141,47 @@ export default function ConnectionsPage() { ) : undefined } - onTest={() => handleTest(c.id)} - onEdit={() => openEditDialog(c)} - onDelete={() => setDeleteTarget(c.id)} - onDuplicate={() => handleDuplicate(c)} + onTest={ + c.isOwner || isAdmin + ? () => handleTest(c.id) + : undefined + } + shared={c.visibility === "shared"} + // Management actions only for the owner (or admin — + // who can reach any connection via the API): shared + // connections render read-only for everyone else (#901). + onEdit={ + c.isOwner || isAdmin + ? () => openEditDialog(c) + : undefined + } + onDelete={ + c.isOwner || isAdmin + ? () => setDeleteTarget(c.id) + : undefined + } + onDuplicate={ + c.isOwner || isAdmin + ? () => handleDuplicate(c) + : undefined + } + onToggleVisibility={ + isAdmin && c.isOwner + ? () => + updateConnection.mutate({ + id: c.id, + visibility: + c.visibility === "shared" + ? "private" + : "shared", + }) + : undefined + } + toggleVisibilityLabel={ + c.visibility === "shared" + ? "Make private" + : "Share with workspace" + } /> {expandedErrorId === c.id && testErrors[c.id] && ( diff --git a/app/src/app/api/connections/[id]/__tests__/route.test.ts b/app/src/app/api/connections/[id]/__tests__/route.test.ts index 4b9d42a6..3bb67073 100644 --- a/app/src/app/api/connections/[id]/__tests__/route.test.ts +++ b/app/src/app/api/connections/[id]/__tests__/route.test.ts @@ -116,22 +116,49 @@ describe("GET /api/connections/[id]", () => { it("returns connection metadata in envelope (owner)", async () => { mockRequireSession.mockResolvedValue(SESSION); + const createdAt = new Date(); const conn = { id: "c1", name: "My DB", type: "postgresql", - createdAt: new Date(), - updatedAt: new Date(), + createdAt, + updatedAt: createdAt, + visibility: "private", + ownerId: "user-1", }; mockDb.select.mockReturnValue(makeSelectChain([conn])); const res = await GET(makeRequest({}), makeParams("c1")); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data).toEqual(conn); + // ownerId is replaced by the isOwner boolean (#901) + expect(body.data.id).toBe("c1"); + expect(body.data.isOwner).toBe(true); + expect(body.data.ownerId).toBeUndefined(); expect(body.error).toBeNull(); }); + it("non-owner can read a tenant-shared connection's metadata, never the owner id (#901)", async () => { + mockRequireSession.mockResolvedValue(SESSION); + const conn = { + id: "c-shared", + name: "Admin Postgres", + type: "postgresql", + createdAt: new Date(), + updatedAt: new Date(), + visibility: "shared", + ownerId: "admin-1", + }; + mockDb.select.mockReturnValue(makeSelectChain([conn])); + + const res = await GET(makeRequest({}), makeParams("c-shared")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.isOwner).toBe(false); + expect(body.data.ownerId).toBeUndefined(); + expect(body.data.visibility).toBe("shared"); + }); + it("admin can view any connection in tenant", async () => { mockRequireSession.mockResolvedValue(ADMIN_SESSION); const conn = { @@ -301,6 +328,45 @@ describe("PATCH /api/connections/[id]", () => { expect(mockDb.update).not.toHaveBeenCalled(); }); + it("rejects visibility changes from non-admins (#901)", async () => { + mockRequireSession.mockResolvedValue(SESSION); + const res = await PATCH( + makeRequest({ visibility: "shared" }), + makeParams("c1"), + ); + expect(res.status).toBe(403); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it("admin can share an owned connection tenant-wide (#901)", async () => { + mockRequireSession.mockResolvedValue(ADMIN_SESSION); + let captured: Record = {}; + const mockSet = vi.fn().mockImplementation((fields) => { + captured = fields; + return { + where: () => ({ + returning: () => + Promise.resolve([ + { + id: "c1", + name: "DB", + type: "postgresql", + visibility: "shared", + }, + ]), + }), + }; + }); + mockDb.update.mockReturnValue({ set: mockSet }); + + const res = await PATCH( + makeRequest({ visibility: "shared" }), + makeParams("c1"), + ); + expect(res.status).toBe(200); + expect(captured.visibility).toBe("shared"); + }); + it("returns 404 when connection not owned", async () => { mockRequireSession.mockResolvedValue(SESSION); mockDb.update.mockReturnValue(makeUpdateChain([])); diff --git a/app/src/app/api/connections/[id]/databases/route.ts b/app/src/app/api/connections/[id]/databases/route.ts index f441fa62..2a061b1e 100644 --- a/app/src/app/api/connections/[id]/databases/route.ts +++ b/app/src/app/api/connections/[id]/databases/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@/lib/db"; import { connections } from "@/lib/db/schema"; import { requireSession } from "@/lib/auth/session"; @@ -22,7 +22,10 @@ export async function GET( .where( and( eq(connections.id, id), - eq(connections.userId, userId), + or( + eq(connections.userId, userId), + eq(connections.visibility, "shared"), + ), eq(connections.tenantId, tenantId), ), ) diff --git a/app/src/app/api/connections/[id]/route.ts b/app/src/app/api/connections/[id]/route.ts index 0ad57b55..7e438f28 100644 --- a/app/src/app/api/connections/[id]/route.ts +++ b/app/src/app/api/connections/[id]/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@/lib/db"; import { connections } from "@/lib/db/schema"; import { requireSession } from "@/lib/auth/session"; @@ -12,6 +12,7 @@ import type { ConnectorType } from "@/lib/connector/connector-types"; import { validateBody, notFound, + forbidden, handleRouteError, badRequest, } from "@/lib/api/api-utils"; @@ -26,7 +27,8 @@ export async function GET( const { userId, tenantId, role } = await requireSession(); const { id } = await params; - // Owner check first (tenant-scoped) + // Owner-or-shared check first (tenant-scoped, #901). Password is + // stripped below either way; shared users get metadata only. let [connection] = await db .select({ id: connections.id, @@ -35,13 +37,18 @@ export async function GET( configEncrypted: connections.configEncrypted, createdAt: connections.createdAt, updatedAt: connections.updatedAt, + visibility: connections.visibility, + ownerId: connections.userId, }) .from(connections) .where( and( eq(connections.id, id), - eq(connections.userId, userId), eq(connections.tenantId, tenantId), + or( + eq(connections.userId, userId), + eq(connections.visibility, "shared"), + ), ), ) .limit(1); @@ -56,6 +63,8 @@ export async function GET( configEncrypted: connections.configEncrypted, createdAt: connections.createdAt, updatedAt: connections.updatedAt, + visibility: connections.visibility, + ownerId: connections.userId, }) .from(connections) .where(and(eq(connections.id, id), eq(connections.tenantId, tenantId))) @@ -66,8 +75,10 @@ export async function GET( return notFound("Connection not found"); } - // Decrypt config and strip password before returning - const { configEncrypted, ...metadata } = connection; + // Decrypt config and strip password before returning. ownerId never + // leaves the server — the UI gates editing on isOwner (#901). + const { configEncrypted, ownerId, ...metadata } = connection; + const shapedMetadata = { ...metadata, isOwner: ownerId === userId }; let config: Record | undefined; if (configEncrypted) { try { @@ -81,7 +92,7 @@ export async function GET( } } - return apiSuccess({ ...metadata, config }); + return apiSuccess({ ...shapedMetadata, config }); } catch (error) { return handleRouteError(error, "Failed to fetch connection"); } @@ -102,6 +113,16 @@ export async function PATCH( const updates: Record = {}; if (result.data.name) updates.name = result.data.name; + // Visibility changes are admin-only (#901 'admin provisions' model) — + // and the update where-clause below keeps them owner-scoped, so an + // admin shares connections they own. + if (result.data.visibility) { + if (role !== "admin") { + return forbidden("Only admins can change connection visibility"); + } + updates.visibility = result.data.visibility; + } + // Fetch the existing row — needed for password merge and cache eviction. let oldCredentials: ConnectionCredentials | null = null; let finalConfig = result.data.config; diff --git a/app/src/app/api/connections/[id]/schema/route.ts b/app/src/app/api/connections/[id]/schema/route.ts index 9c75ff25..e77b4caa 100644 --- a/app/src/app/api/connections/[id]/schema/route.ts +++ b/app/src/app/api/connections/[id]/schema/route.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@/lib/db"; import { connections } from "@/lib/db/schema"; import { requireSession } from "@/lib/auth/session"; @@ -14,13 +14,22 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { try { - const { userId } = await requireSession(); + const { userId, tenantId } = await requireSession(); const { id } = await params; const [connection] = await db .select() .from(connections) - .where(and(eq(connections.id, id), eq(connections.userId, userId))) + .where( + and( + eq(connections.id, id), + eq(connections.tenantId, tenantId), + or( + eq(connections.userId, userId), + eq(connections.visibility, "shared"), + ), + ), + ) .limit(1); if (!connection) { diff --git a/app/src/app/api/connections/__tests__/route.test.ts b/app/src/app/api/connections/__tests__/route.test.ts index c18fd620..cf06b035 100644 --- a/app/src/app/api/connections/__tests__/route.test.ts +++ b/app/src/app/api/connections/__tests__/route.test.ts @@ -85,13 +85,16 @@ describe("GET /api/connections", () => { it("returns connections in envelope with pagination meta for non-admin", async () => { mockRequireSession.mockResolvedValue(SESSION); + const createdAt = new Date(); const rows = [ { id: "c1", name: "My DB", type: "postgresql", - createdAt: new Date(), - updatedAt: new Date(), + createdAt, + updatedAt: createdAt, + visibility: "private", + ownerId: "user-1", }, ]; mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }])); @@ -100,11 +103,37 @@ describe("GET /api/connections", () => { const res = await GET(makeRequest({}, "http://localhost/api/connections")); expect(res.status).toBe(200); const body = await res.json(); - expect(body.data).toEqual(rows); + // ownerId never leaves the server — it becomes the isOwner boolean (#901) + expect(body.data[0].isOwner).toBe(true); + expect(body.data[0].ownerId).toBeUndefined(); + expect(body.data[0].visibility).toBe("private"); expect(body.meta).toEqual({ total: 1, limit: 25, offset: 0 }); expect(body.error).toBeNull(); }); + it("marks tenant-shared connections not owned by the caller (#901)", async () => { + mockRequireSession.mockResolvedValue(SESSION); + const rows = [ + { + id: "c-shared", + name: "Admin Postgres", + type: "postgresql", + createdAt: new Date(), + updatedAt: new Date(), + visibility: "shared", + ownerId: "admin-1", + }, + ]; + mockDb.select.mockReturnValueOnce(makeSelectChain([{ count: 1 }])); + mockDb.select.mockReturnValueOnce(makeSelectChain(rows)); + + const res = await GET(makeRequest({}, "http://localhost/api/connections")); + const body = await res.json(); + expect(body.data[0].isOwner).toBe(false); + expect(body.data[0].visibility).toBe("shared"); + expect(body.data[0].ownerId).toBeUndefined(); + }); + it("admin sees all connections in tenant", async () => { mockRequireSession.mockResolvedValue(ADMIN_SESSION); const rows = [ diff --git a/app/src/app/api/connections/route.ts b/app/src/app/api/connections/route.ts index a91305e5..8aeba6a4 100644 --- a/app/src/app/api/connections/route.ts +++ b/app/src/app/api/connections/route.ts @@ -1,4 +1,4 @@ -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, or } from "drizzle-orm"; import { db } from "@/lib/db"; import { connections } from "@/lib/db/schema"; import { requireSession } from "@/lib/auth/session"; @@ -15,10 +15,17 @@ export async function GET(request: Request) { const { limit, offset } = parsePagination(request); const isAdmin = role === "admin"; - // Admin sees all connections in the tenant; non-admin sees only own. + // Admin sees all connections in the tenant; non-admin sees own plus + // tenant-wide shared ones (#901). const whereClause = isAdmin ? eq(connections.tenantId, tenantId) - : and(eq(connections.userId, userId), eq(connections.tenantId, tenantId)); + : and( + eq(connections.tenantId, tenantId), + or( + eq(connections.userId, userId), + eq(connections.visibility, "shared"), + ), + ); const [{ count: total }] = await db .select({ count: count() }) @@ -33,6 +40,8 @@ export async function GET(request: Request) { allowPerCardDb: connections.allowPerCardDb, createdAt: connections.createdAt, updatedAt: connections.updatedAt, + visibility: connections.visibility, + ownerId: connections.userId, }) .from(connections) .where(whereClause) @@ -40,7 +49,14 @@ export async function GET(request: Request) { .orderBy(connections.createdAt) .offset(offset); - return apiList(rows, { total: Number(total), limit, offset }); + // Expose ownership as a boolean — the UI gates edit/delete on it (#901). + // Never leak the raw owner id to non-admins. + const shaped = rows.map(({ ownerId, ...rest }) => ({ + ...rest, + isOwner: ownerId === userId, + })); + + return apiList(shaped, { total: Number(total), limit, offset }); } catch (error) { return handleRouteError(error, "Failed to fetch connections"); } diff --git a/app/src/app/api/query/route.ts b/app/src/app/api/query/route.ts index e4c8fb39..f43a1fc6 100644 --- a/app/src/app/api/query/route.ts +++ b/app/src/app/api/query/route.ts @@ -71,15 +71,20 @@ async function handleReadQuery(request: Request): Promise { return forbidden("Tenant mismatch"); } - // 1. Fast path: direct ownership (tenant-scoped) + // 1. Fast path: direct ownership or tenant-shared connection (#901). + // Shared connections are first-class queryable for every tenant + // user — that's the 'admin provisions, all use' model. let [connection] = await db .select() .from(connections) .where( and( eq(connections.id, connectionId), - eq(connections.userId, userId), eq(connections.tenantId, sessionTenantId), + or( + eq(connections.userId, userId), + eq(connections.visibility, "shared"), + ), ), ) .limit(1); diff --git a/app/src/hooks/use-connections.ts b/app/src/hooks/use-connections.ts index 7318a913..3d538761 100644 --- a/app/src/hooks/use-connections.ts +++ b/app/src/hooks/use-connections.ts @@ -10,6 +10,10 @@ export interface ConnectionListItem { type: ConnectorType; /** When true, widgets can override the connection's default database per-card. */ allowPerCardDb: boolean; + /** "shared" connections are queryable by every user in the tenant (#901). */ + visibility: "private" | "shared"; + /** True when the current user owns the connection — gates edit/delete UI. */ + isOwner: boolean; createdAt: string; updatedAt: string; } @@ -147,6 +151,8 @@ export function useReassignConnection() { export interface UpdateConnectionInput { id: string; name?: string; + /** Admin-only: toggle tenant-wide sharing (#901). */ + visibility?: "private" | "shared"; config?: Partial<{ uri: string; username: string; diff --git a/app/src/lib/db/schema.ts b/app/src/lib/db/schema.ts index 515dbb8e..4c3cc28c 100644 --- a/app/src/lib/db/schema.ts +++ b/app/src/lib/db/schema.ts @@ -99,6 +99,11 @@ export const connectionTypeEnum = pgEnum("connection_type", [ "postgresql", ]); +export const connectionVisibilityEnum = pgEnum("connection_visibility", [ + "private", + "shared", +]); + export const connections = pgTable("connection", { id: text("id") .primaryKey() @@ -112,6 +117,15 @@ export const connections = pgTable("connection", { configEncrypted: text("configEncrypted").notNull(), /** When true, widget editors can override the connection's default database per-card. */ allowPerCardDb: boolean("allow_per_card_db").notNull().default(true), + /** + * Connection sharing model (#901): "private" = owner + admins only; + * "shared" = every user in the tenant may query it and build dashboards + * on it. Credentials are never exposed either way; editing stays + * owner/admin-only. + */ + visibility: connectionVisibilityEnum("visibility") + .notNull() + .default("private"), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(), }); diff --git a/app/src/lib/shared/schemas.ts b/app/src/lib/shared/schemas.ts index c1e6c188..ae84f919 100644 --- a/app/src/lib/shared/schemas.ts +++ b/app/src/lib/shared/schemas.ts @@ -47,6 +47,8 @@ export const updateConnectionConfigSchema = connectionConfigSchema.extend({ export const updateConnectionSchema = z.object({ name: z.string().min(1).optional(), config: updateConnectionConfigSchema.optional(), + /** #901 — admin-only; toggles tenant-wide read/query access. */ + visibility: z.enum(["private", "shared"]).optional(), }); export const testInlineSchema = z.object({ diff --git a/component/src/components/composed/connection-card.tsx b/component/src/components/composed/connection-card.tsx index 9e5506bc..67f788bb 100644 --- a/component/src/components/composed/connection-card.tsx +++ b/component/src/components/composed/connection-card.tsx @@ -1,5 +1,14 @@ -import { Database, MoreVertical, Pencil, Trash2, RefreshCw, Copy } from "lucide-react"; +import { + Database, + MoreVertical, + Pencil, + Trash2, + RefreshCw, + Copy, + Users, +} from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -24,6 +33,11 @@ export interface ConnectionCardProps { onTest?: () => void; onDuplicate?: () => void; onClick?: () => void; + /** Renders a "Shared" badge — the connection is workspace-visible. */ + shared?: boolean; + /** Menu action to toggle sharing; label comes from toggleVisibilityLabel. */ + onToggleVisibility?: () => void; + toggleVisibilityLabel?: string; className?: string; } @@ -39,6 +53,9 @@ function ConnectionCard({ onTest, onDuplicate, onClick, + shared = false, + onToggleVisibility, + toggleVisibilityLabel = "Share with workspace", className, }: ConnectionCardProps) { return ( @@ -47,7 +64,7 @@ function ConnectionCard({ "transition-colors", active && "border-primary", onClick && "cursor-pointer hover:bg-accent/50", - className + className, )} onClick={onClick} > @@ -62,13 +79,23 @@ function ConnectionCard({ status={status} errorMessage={status === "error" ? statusText : undefined} /> + {shared && ( + + + Shared + + )}

{host} {database && ` / ${database}`}

- {(onEdit || onDelete || onTest || onDuplicate) && ( + {(onEdit || + onDelete || + onTest || + onDuplicate || + onToggleVisibility) && (