diff --git a/.github/workflows/main_cliniciantoolkit.yml b/.github/workflows/azure_deployment.yml similarity index 86% rename from .github/workflows/main_cliniciantoolkit.yml rename to .github/workflows/azure_deployment.yml index 2a1bc50..9ebfc80 100644 --- a/.github/workflows/main_cliniciantoolkit.yml +++ b/.github/workflows/azure_deployment.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - development workflow_dispatch: env: @@ -28,7 +29,6 @@ jobs: - name: npm install, build, and test run: | npm install - npx playwright install --with-deps npm run build --if-present npm run test --if-present @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest needs: build environment: - name: "Production" + name: ${{ github.ref == 'refs/heads/main' && 'production' || 'development' }} url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} steps: @@ -62,6 +62,6 @@ jobs: uses: azure/webapps-deploy@v3 with: app-name: "cliniciantoolkit" - slot-name: "Production" + slot-name: ${{ github.ref == 'refs/heads/main' && 'production' || 'development' }} package: . - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_9A3AB102193241559E3EADB479BA0400 }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D8F19C70B5EA48B29386C08288419993 }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8c541cb..448b9b5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -26,7 +26,6 @@ jobs: - name: Install dependencies run: | npm install - npx playwright install --with-deps - name: Run tests run: | npm run test diff --git a/integration_tests/navbar.test.ts b/integration_tests/navbar.test.ts deleted file mode 100644 index a296505..0000000 --- a/integration_tests/navbar.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from "@playwright/test" - -test("navbar redirects to templates", async ({ page }) => { - await page.goto("/summarization") - const homeLink = page.getByAltText("Clinician Toolkit") - expect(homeLink).toBeTruthy() - - await homeLink.click() - await page.waitForURL("/templates") - - expect(page.url()).toMatch(/http:\/\/localhost:[0-9]+\/templates$/) -}) diff --git a/package.json b/package.json index 38dd0ff..1f4c5b4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "test": "npm run test:integration && npm run test:unit", + "test": "npm run test:unit", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --plugin-search-dir . --check 'src/**/*.{js,ts}' && eslint 'src/**/*.{js,ts}'", diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts deleted file mode 100644 index 50b62b8..0000000 --- a/src/hooks.server.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { handle } from "../src/hooks.server" -import { describe, it, expect, vi } from "vitest" - -function createEvent() { - return { - request: { - headers: new Map(), - method: "GET", - url: "https://example.com" - } - } -} - -function createResolve() { - return vi.fn().mockResolvedValue({ - status: 200, - headers: { - append: vi.fn(), - get: vi.fn(), - set: vi.fn() - } - }) -} - -describe("handle requests", () => { - it("should set request headers and log request information", async () => { - const event = createEvent() - const resolve = createResolve() - - const response = await handle({ event, resolve }) - - expect(event.request.headers.get("X-Request-ID")).toBeDefined() - expect(event.request.headers.get("X-User")).toBe("development.user@example.com") - expect(resolve).toHaveBeenCalledWith(event) - expect(response.headers.append).toHaveBeenCalledWith("X-Request-ID", expect.any(String)) - }) -}) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8395c54..78bf4cb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,21 +1,42 @@ import { logger } from "$lib/server/logging" import { randomUUID } from "crypto" import { performance } from "perf_hooks" +import { pool } from "$lib/server/sql" +import { DEVELOPMENT_USER } from "$lib/server/environment" +import type { User } from "$lib/databaseTypes" +import type { RequestEvent } from "@sveltejs/kit" + +const ADMIN_ENDPOINTS = ["/api/templates/.*?"] export async function handle({ event, resolve }) { const requestId = randomUUID() const startTime = performance.now() - const user = event.request.headers.get("X-MS-CLIENT-PRINCIPAL-NAME") || "development.user@example.com" + const userEmail = event.request.headers.get("X-MS-CLIENT-PRINCIPAL-NAME") || DEVELOPMENT_USER logger.info({ type: `Request`, method: event.request.method, url: event.request.url, - user, + user: userEmail, requestId }) event.request.headers.set("X-Request-ID", requestId) - event.request.headers.set("X-User", user) + event.request.headers.set("X-User", userEmail) + + const user = await getOrInsertUser(userEmail) + if (!isUserAuthorized(event, user)) { + const endTime = performance.now() + const responseTime = `${(endTime - startTime).toFixed(3)}ms` + logger.error({ + type: "Unauthorized", + method: event.request.method, + url: event.request.url, + user: userEmail, + requestId, + responseTime + }) + return new Response("Unauthorized", { status: 401 }) + } const response = await resolve(event) @@ -26,7 +47,7 @@ export async function handle({ event, resolve }) { statusCode: response.status, method: event.request.method, url: event.request.url, - user, + user: userEmail, requestId, responseTime } @@ -41,3 +62,33 @@ export async function handle({ event, resolve }) { return response } + +// Exported for testing purposes. +export async function getOrInsertUser(email: string) { + return await pool.connect().then(async client => { + const getUserQuery = { + text: "SELECT * FROM users WHERE email = $1", + values: [email] + } + const getResult = await client.query(getUserQuery) + if (getResult.rows.length > 0) { + client.release() + return getResult.rows[0] as User + } + + const insertUserQuery = { + text: "INSERT INTO users (email) VALUES ($1) RETURNING *", + values: [email] + } + const insertResult = await client.query(insertUserQuery) + client.release() + return insertResult.rows[0] as User + }) +} + +function isUserAuthorized(event: RequestEvent>, string | null>, user: User) { + if (!user.is_admin && ADMIN_ENDPOINTS.some(endpoint => event.request.url.match(endpoint))) { + return false + } + return true +} diff --git a/src/lib/databaseTypes.ts b/src/lib/databaseTypes.ts new file mode 100644 index 0000000..c3e6ecd --- /dev/null +++ b/src/lib/databaseTypes.ts @@ -0,0 +1,6 @@ +export type User = { + id: number + email: string + is_admin: boolean + is_alpha_user: boolean +} diff --git a/src/lib/server/azure.ts b/src/lib/server/azure.ts index 4b930b3..0f61d25 100644 --- a/src/lib/server/azure.ts +++ b/src/lib/server/azure.ts @@ -1,4 +1,4 @@ -import { AZURE_BLOB_ACCOUNT_NAME, AZURE_BLOB_SAS } from "./secrets" +import { AZURE_BLOB_ACCOUNT_NAME, AZURE_BLOB_SAS } from "./environment" import { logger } from "./logging" if (!AZURE_BLOB_ACCOUNT_NAME || !AZURE_BLOB_SAS) { diff --git a/src/lib/server/secrets.ts b/src/lib/server/environment.ts similarity index 91% rename from src/lib/server/secrets.ts rename to src/lib/server/environment.ts index 118a2ea..e8afbf3 100644 --- a/src/lib/server/secrets.ts +++ b/src/lib/server/environment.ts @@ -11,3 +11,5 @@ export const AZURE_FUNCTION_PYTHON_KEY = env.AZURE_FUNCTION_PYTHON_KEY export const AZURE_BLOB_ACCOUNT_NAME = env.AZURE_BLOB_ACCOUNT_NAME export const AZURE_BLOB_SAS = env.AZURE_BLOB_SAS + +export const DEVELOPMENT_USER = env.DEVELOPMENT_USER diff --git a/src/lib/server/sql.ts b/src/lib/server/sql.ts index 811d8dd..88fb528 100644 --- a/src/lib/server/sql.ts +++ b/src/lib/server/sql.ts @@ -1,5 +1,5 @@ import pkg from "pg" -import { POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_PORT } from "./secrets" +import { POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_PORT } from "./environment" const { Pool } = pkg const config = { diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..1a607ff --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,25 @@ +import type { User } from "$lib/databaseTypes" +import { DEVELOPMENT_USER } from "$lib/server/environment.js" +import { pool } from "$lib/server/sql" + +export async function load({ request }) { + const userEmail = request.headers.get("X-MS-CLIENT-PRINCIPAL-NAME") || DEVELOPMENT_USER + const userQuery = { + text: "SELECT * FROM users WHERE email = $1", + values: [userEmail] + } + const user: User = await pool.connect().then(async client => { + const result = await client.query(userQuery) + client.release() + return result.rows[0] + }) + + if (!user) { + return { + status: 401, + error: "User not found." + } + } + + return { user } +} diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index 02b5a90..2fb604d 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,5 +1,5 @@ import { logger } from "$lib/server/logging" -import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/secrets" +import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/environment" export async function GET() { logger.info("Warming up the server.") diff --git a/src/routes/api/intake-report/[id]/+server.ts b/src/routes/api/intake-report/[id]/+server.ts index 4e40201..03c30c2 100644 --- a/src/routes/api/intake-report/[id]/+server.ts +++ b/src/routes/api/intake-report/[id]/+server.ts @@ -1,5 +1,5 @@ import { logger } from "$lib/server/logging" -import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/secrets" +import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/environment" export async function GET({ params, fetch }) { const id = params.id diff --git a/src/routes/api/llm/+server.ts b/src/routes/api/llm/+server.ts index 6b03539..ebd2b93 100644 --- a/src/routes/api/llm/+server.ts +++ b/src/routes/api/llm/+server.ts @@ -1,6 +1,6 @@ import { env } from "$env/dynamic/private" import { logger } from "$lib/server/logging" -import { AZURE_FUNCTION_PYTHON_KEY } from "$lib/server/secrets" +import { AZURE_FUNCTION_PYTHON_KEY } from "$lib/server/environment" export async function POST({ fetch, request }) { logger.info("Making LLM request.") diff --git a/src/routes/api/markdown2docx/+server.ts b/src/routes/api/markdown2docx/+server.ts index 5438795..a395e43 100644 --- a/src/routes/api/markdown2docx/+server.ts +++ b/src/routes/api/markdown2docx/+server.ts @@ -1,5 +1,5 @@ import { logger } from "$lib/server/logging" -import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/secrets" +import { AZURE_FUNCTION_PYTHON_KEY, AZURE_FUNCTION_PYTHON_URL } from "$lib/server/environment" export async function POST({ fetch, request }) { logger.info("Converting markdown to docx") diff --git a/src/routes/api/templates/[id]/+server.ts b/src/routes/api/templates/[id]/+server.ts index 1197a57..d4fb496 100644 --- a/src/routes/api/templates/[id]/+server.ts +++ b/src/routes/api/templates/[id]/+server.ts @@ -26,31 +26,6 @@ export async function POST({ request, params }) { }) } -export async function GET({ params }) { - const id = params.id - logger.info(`Getting template with id ${id}`) - - const query = { - text: "SELECT * FROM templates WHERE id = $1", - values: [id] - } - - return await pool - .connect() - .then(async client => { - const result = await client.query(query) - client.release() - return result.rows[0] - }) - .then(row => { - return new Response(JSON.stringify(row), { headers: { "Content-Type": "application/json" } }) - }) - .catch(error => { - logger.error(`Error getting template with id ${id}:`, error) - return new Response(null, { status: 500 }) - }) -} - export async function PATCH({ params, request }) { const id = params.id let { text, parent_id } = await request.json() diff --git a/src/routes/templates/+page.svelte b/src/routes/templates/+page.svelte index e6954dc..38c607d 100644 --- a/src/routes/templates/+page.svelte +++ b/src/routes/templates/+page.svelte @@ -8,6 +8,8 @@ import SelectedNodes from "./SelectedNodes.svelte" import MarkdownEditor from "$lib/components/MarkdownEditor.svelte" + export let data + let selectedNodes: DecisionTree[] = [] let tabSet: number = 0 let editable: boolean = false @@ -44,9 +46,11 @@