From 7cb070aaf930dcb572c20ed25058ca14a80d0639 Mon Sep 17 00:00:00 2001 From: blobcode Date: Fri, 16 Oct 2026 20:54:38 -0400 Subject: [PATCH] remove auth service --- .gitignore | 3 +- auth-service/.dockerignore | 2 - auth-service/Dockerfile | 13 - auth-service/README.md | 26 - auth-service/package-lock.json | 396 --------- auth-service/package.json | 11 - auth-service/server.mjs | 73 -- deploy/k3s/README.md | 2 +- deploy/k3s/mist-api.yaml | 24 +- deploy/k3s/mist-auth.yaml | 103 --- deploy/k3s/team-rbac.yaml | 19 - deploy/k3s/verify_department.mjs | 27 - deploy/k3s/verify_department_browser.mjs | 9 - deploy/private/README.md | 15 +- deploy/private/backup-auth.sh | 9 +- deploy/private/build-and-deploy.sh | 7 +- docs/architecture.md | 9 +- docs/department-rollout.md | 8 +- go.work | 4 +- go.work.sum | 15 + scripts/dev.sh | 28 +- src/app.go | 18 +- src/auth.go | 891 +++++++++++++++++++ src/auth_test.go | 266 ++++++ src/go.mod | 19 +- src/go.sum | 29 + src/main.go | 3 + src/member_auth.go | 152 +--- src/shared_storage_test.go | 29 +- src/team_queue.go | 7 +- src/teams.go | 28 - src/teams_test.go | 35 +- web-interface/src/auth.tsx | 1 - web-interface/src/components/AccountPage.tsx | 33 +- web-interface/src/components/TeamsPage.tsx | 12 +- 35 files changed, 1335 insertions(+), 991 deletions(-) delete mode 100644 auth-service/.dockerignore delete mode 100644 auth-service/Dockerfile delete mode 100644 auth-service/README.md delete mode 100644 auth-service/package-lock.json delete mode 100644 auth-service/package.json delete mode 100644 auth-service/server.mjs delete mode 100644 deploy/k3s/mist-auth.yaml create mode 100644 src/auth.go create mode 100644 src/auth_test.go diff --git a/.gitignore b/.gitignore index 2a79687..40ae98d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,5 @@ bin/ /logs/ __pycache__/ *.py[cod] -auth-service/node_modules/ -auth-service/data/ +data/ .direnv/ diff --git a/auth-service/.dockerignore b/auth-service/.dockerignore deleted file mode 100644 index 15de390..0000000 --- a/auth-service/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -data diff --git a/auth-service/Dockerfile b/auth-service/Dockerfile deleted file mode 100644 index 17ae551..0000000 --- a/auth-service/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:22.16.0-bookworm-slim AS dependencies -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* -COPY package*.json ./ -RUN npm ci --omit=dev -FROM node:22.16.0-bookworm-slim -ENV NODE_ENV=production HOST=0.0.0.0 PORT=3002 MIST_AUTH_DATABASE=/data/auth.sqlite -WORKDIR /app -COPY --from=dependencies /app/node_modules ./node_modules -COPY package.json server.mjs ./ -USER 65532:65532 -EXPOSE 3002 -CMD ["node", "server.mjs"] diff --git a/auth-service/README.md b/auth-service/README.md deleted file mode 100644 index d4c168e..0000000 --- a/auth-service/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Mist member authentication - -Small Node service using maintained Better Auth 1.7.7 and better-sqlite3 13.0.3. -It is internal to `mist-system`; the Go gateway proxies `/auth` on the website's -origin and checks sessions before authorizing data routes. No public signup. - -`npm ci && npm start` requires Node 22.16 or newer and these environment variables: - -- `BETTER_AUTH_SECRET`: protected random secret, at least 32 characters. -- `MIST_SITE_ORIGIN`: exact website origin; HTTPS enables Secure cookies. -- `MIST_ALLOWED_ORIGINS`: comma-separated trusted origins. -- `MIST_AUTH_DATABASE`: local SQLite path (default `./data/auth.sqlite`). -- `MIST_ADMIN_EMAIL`, `MIST_ADMIN_PASSWORD`: only needed when initializing an empty - database. The first user is an administrator. Existing users are never reset. -- `HOST`, `PORT`: listener (default localhost:3002). - -Never put a real secret or bootstrap password in the image, Git, shell arguments -or logs. Kubernetes injects the protected Secret. SQLite uses WAL, a busy timeout -and startup migrations on a persistent local PVC, with one Recreate replica. -Database-backed rate limiting survives restarts. Upload/storage metadata live -in Go/NFS, not in this auth database. Member passwords use the library's password -implementation; Go does not hash passwords or mint authentication tokens. - -See [deployment operations](../deploy/private/README.md) for persistent-state -handling and private HTTP deployment. Automatic backups/recovery and HTTPS were -excluded from this release; the existing backup script is a manual utility. diff --git a/auth-service/package-lock.json b/auth-service/package-lock.json deleted file mode 100644 index 28c2a92..0000000 --- a/auth-service/package-lock.json +++ /dev/null @@ -1,396 +0,0 @@ -{ - "name": "mist-auth", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mist-auth", - "dependencies": { - "better-auth": "1.7.7", - "better-sqlite3": "13.0.3" - }, - "engines": { - "node": ">=22.16.0" - } - }, - "node_modules/@better-auth/core": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", - "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", - "license": "MIT", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.41.1", - "@standard-schema/spec": "^1.1.0", - "zod": "^4.5.4" - }, - "peerDependencies": { - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2", - "@opentelemetry/api": "^1.9.0", - "better-call": "1.4.0", - "jose": "^6.1.0", - "kysely": "^0.28.5 || ^0.29.0", - "nanostores": "^1.0.1" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - } - } - }, - "node_modules/@better-auth/drizzle-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", - "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" - }, - "peerDependenciesMeta": { - "drizzle-orm": { - "optional": true - } - } - }, - "node_modules/@better-auth/kysely-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", - "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "kysely": "^0.28.17 || ^0.29.0" - }, - "peerDependenciesMeta": { - "kysely": { - "optional": true - } - } - }, - "node_modules/@better-auth/memory-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", - "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2" - } - }, - "node_modules/@better-auth/mongo-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", - "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "mongodb": "^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "mongodb": { - "optional": true - } - } - }, - "node_modules/@better-auth/prisma-adapter": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", - "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "@prisma/client": { - "optional": true - }, - "prisma": { - "optional": true - } - } - }, - "node_modules/@better-auth/telemetry": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", - "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", - "license": "MIT", - "peerDependencies": { - "@better-auth/core": "^1.7.7", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2" - } - }, - "node_modules/@better-auth/utils": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", - "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" - } - }, - "node_modules/@better-fetch/fetch": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", - "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", - "license": "MIT" - }, - "node_modules/@noble/ciphers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", - "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/better-auth": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", - "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", - "license": "MIT", - "dependencies": { - "@better-auth/core": "1.7.7", - "@better-auth/drizzle-adapter": "1.7.7", - "@better-auth/kysely-adapter": "1.7.7", - "@better-auth/memory-adapter": "1.7.7", - "@better-auth/mongo-adapter": "1.7.7", - "@better-auth/prisma-adapter": "1.7.7", - "@better-auth/telemetry": "1.7.7", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2", - "@noble/ciphers": "^2.2.0", - "@noble/hashes": "^2.2.0", - "better-call": "1.4.0", - "defu": "^6.1.4", - "jose": "^6.2.3", - "kysely": "^0.28.17 || ^0.29.0", - "nanostores": "^1.3.0", - "zod": "^4.5.4" - }, - "peerDependencies": { - "@lynx-js/react": "*", - "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", - "@sveltejs/kit": "^2.0.0", - "@tanstack/react-start": "^1.0.0", - "@tanstack/solid-start": "^1.0.0", - "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", - "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", - "mongodb": "^6.0.0 || ^7.0.0", - "mysql2": "^3.0.0", - "next": "^14.0.0 || ^15.0.0 || ^16.0.0", - "pg": "^8.0.0", - "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "solid-js": "^1.0.0", - "svelte": "^4.0.0 || ^5.0.0", - "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "vue": "^3.0.0" - }, - "peerDependenciesMeta": { - "@lynx-js/react": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "@tanstack/react-start": { - "optional": true - }, - "@tanstack/solid-start": { - "optional": true - }, - "drizzle-kit": { - "optional": true - }, - "drizzle-orm": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "next": { - "optional": true - }, - "pg": { - "optional": true - }, - "prisma": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vitest": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, - "node_modules/better-call": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", - "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", - "license": "MIT", - "dependencies": { - "@better-auth/utils": "^0.5.0", - "@better-fetch/fetch": "^1.3.1", - "rou3": "^0.9.1", - "set-cookie-parser": "^3.1.2" - }, - "peerDependencies": { - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/better-call/node_modules/@better-auth/utils": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", - "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^2.0.1" - } - }, - "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/kysely": { - "version": "0.29.6", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", - "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", - "license": "MIT", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/nanostores": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", - "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, - "node_modules/node-addon-api": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", - "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/rou3": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", - "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", - "license": "MIT" - }, - "node_modules/set-cookie-parser": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", - "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", - "license": "MIT" - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/auth-service/package.json b/auth-service/package.json deleted file mode 100644 index 411117f..0000000 --- a/auth-service/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "mist-auth", - "private": true, - "type": "module", - "engines": { "node": ">=22.16.0" }, - "scripts": { "start": "node server.mjs" }, - "dependencies": { - "better-auth": "1.7.7", - "better-sqlite3": "13.0.3" - } -} diff --git a/auth-service/server.mjs b/auth-service/server.mjs deleted file mode 100644 index 42cb089..0000000 --- a/auth-service/server.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import { createServer } from 'node:http' -import { mkdirSync } from 'node:fs' -import { dirname } from 'node:path' -import { timingSafeEqual } from 'node:crypto' -import Database from 'better-sqlite3' -import { betterAuth } from 'better-auth' -import { getMigrations } from 'better-auth/db/migration' -import { toNodeHandler } from 'better-auth/node' -import { admin } from 'better-auth/plugins/admin' -import { APIError, createAuthMiddleware } from 'better-auth/api' - -const dbPath = process.env.MIST_AUTH_DATABASE ?? './data/auth.sqlite' -const origin = process.env.MIST_SITE_ORIGIN ?? 'http://127.0.0.1:3001' -const secret = process.env.BETTER_AUTH_SECRET -if (!secret || secret.length < 32) throw new Error('BETTER_AUTH_SECRET must contain at least 32 characters') -mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 }) -const database = new Database(dbPath) -database.pragma('journal_mode = WAL') -database.pragma('busy_timeout = 5000') -const options = { - appName: 'Mist', database, secret, baseURL: origin, basePath: '/auth', - trustedOrigins: (process.env.MIST_ALLOWED_ORIGINS ?? origin).split(',').filter(Boolean), - emailAndPassword: { enabled: true, disableSignUp: true, minPasswordLength: 12, maxPasswordLength: 128 }, - session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: false } }, - advanced: { useSecureCookies: origin.startsWith('https:'), ipAddress: { ipAddressHeaders: ['x-real-ip'] } }, - rateLimit: { enabled: true, storage: 'database', window: 60, max: 120, - customRules: { '/get-session': false, '/sign-in/email': { window: 60, max: 8 } } }, - plugins: [admin()], - hooks: { before: createAuthMiddleware(async (ctx) => { - if (ctx.path === '/admin/create-user' || ctx.path === '/admin/set-user-password') { - const password = ctx.body?.password ?? ctx.body?.newPassword - if (typeof password !== 'string' || password.length < 12 || password.length > 128) - throw new APIError('BAD_REQUEST', { message: 'Password must contain 12–128 characters' }) - } - }) }, -} -await (await getMigrations(options)).runMigrations() -const auth = betterAuth(options) -if (!database.prepare('SELECT id FROM user LIMIT 1').get()) { - const email = process.env.MIST_ADMIN_EMAIL - const password = process.env.MIST_ADMIN_PASSWORD - if (!email || !password || password.length < 12) throw new Error('Initial admin credentials are required for an empty database') - // A server-only SDK call, before the network listener exists. Public signup stays disabled. - await auth.api.createUser({ body: { name: 'Mist Administrator', email, password, role: 'admin' } }) - console.log('Initial Mist administrator created; credentials are not logged') -} -const handler = toNodeHandler(auth) -const server = createServer((req, res) => { - if (req.url === '/internal/members' && req.method === 'GET') { - const configured = process.env.MIST_INTERNAL_TOKEN ?? '' - const presented = req.headers.authorization?.replace(/^Bearer /, '') ?? '' - if (configured.length < 32 || Buffer.byteLength(configured) !== Buffer.byteLength(presented) || - !timingSafeEqual(Buffer.from(configured), Buffer.from(presented))) { - res.writeHead(403); res.end(); return - } - const members = database.prepare('SELECT id, banned, role FROM user').all() - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) - res.end(JSON.stringify({ members: members.map(({ id, banned, role }) => ({ id, active: !banned, role })) })) - return - } - if (req.url === '/healthz') { - try { database.prepare('SELECT 1').get(); res.writeHead(200); res.end('ok') } - catch { res.writeHead(503); res.end('database unavailable') } - return - } - if (!req.url?.startsWith('/auth/')) { res.writeHead(404); res.end(); return } - // Only the same-origin Go proxy is exposed to clients. It replaces this header. - void handler(req, res) -}) -server.requestTimeout = 30000 -server.headersTimeout = 10000 -server.listen(Number(process.env.PORT ?? 3002), process.env.HOST ?? '127.0.0.1', () => console.log('Mist auth service ready')) -for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close(() => { database.close(); process.exit(0) })) diff --git a/deploy/k3s/README.md b/deploy/k3s/README.md index 964cc37..cb60407 100644 --- a/deploy/k3s/README.md +++ b/deploy/k3s/README.md @@ -8,7 +8,7 @@ and [deployment operations](../private/README.md). | Assets | Purpose | |---|---| -| `mist-api.yaml`, `mist-auth.yaml` | Single API/controller and Better Auth deployment | +| `mist-api.yaml` | Single API, built-in auth, and controller deployment | | `team-rbac.yaml`, `team-workload-policy.yaml` | Team provisioning authority and workload admission | | `nvidia-gpu-operator-values.yaml` | Pinned host-driver/CDI configuration | | `tenstorrent-operator-values.yaml`, `mist-tenstorrent-claims.yaml` | TT operator configuration and retained legacy claims | diff --git a/deploy/k3s/mist-api.yaml b/deploy/k3s/mist-api.yaml index 012044d..f41c783 100644 --- a/deploy/k3s/mist-api.yaml +++ b/deploy/k3s/mist-api.yaml @@ -163,8 +163,23 @@ spec: # Registry-backed images are approved here using exact references. - name: MIST_ALLOWED_IMAGES value: "python:3.10-slim,pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime,ghcr.io/tenstorrent/tt-metal/tt-metalium/ubuntu-22.04-dev-amd64:6b765c0bc2b40ea02566d169d7b0ee902377ba04,busybox:1.36,alpine:3.16.3,nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0,mist-training:cpu-v1,mist-training:nvidia-v1" - - name: MIST_AUTH_URL - value: http://mist-auth.mist-system.svc.cluster.local:3002 + - name: MIST_AUTH_DATABASE + value: /data/auth.sqlite + - name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: mist-auth-bootstrap + key: BETTER_AUTH_SECRET + - name: MIST_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: mist-auth-bootstrap + key: MIST_ADMIN_EMAIL + - name: MIST_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: mist-auth-bootstrap + key: MIST_ADMIN_PASSWORD - name: MIST_TRUSTED_PROXY_IPS value: "10.42.0.1,10.0.0.175,127.0.0.1" - name: MIST_LEGACY_OWNER_EMAIL @@ -178,6 +193,8 @@ spec: volumeMounts: - name: storage mountPath: /storage + - name: auth-data + mountPath: /data securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -205,6 +222,9 @@ spec: - name: storage persistentVolumeClaim: claimName: mist-shared-api + - name: auth-data + persistentVolumeClaim: + claimName: mist-auth-data --- apiVersion: v1 kind: Service diff --git a/deploy/k3s/mist-auth.yaml b/deploy/k3s/mist-auth.yaml deleted file mode 100644 index ef90d81..0000000 --- a/deploy/k3s/mist-auth.yaml +++ /dev/null @@ -1,103 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: mist-auth-data - namespace: mist-system -spec: - storageClassName: local-path - accessModes: [ReadWriteOnce] - resources: - requests: - storage: 1Gi ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mist-auth - namespace: mist-system -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - app: mist-auth - template: - metadata: - labels: - app: mist-auth - spec: - automountServiceAccountToken: false - nodeSelector: - kubernetes.io/hostname: utmist-z1opa08 - securityContext: - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - fsGroup: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: auth - image: mist-auth:departments-20261009 - imagePullPolicy: Never - ports: - - name: http - containerPort: 3002 - envFrom: - - secretRef: - name: mist-auth-bootstrap - env: - - name: MIST_INTERNAL_TOKEN - valueFrom: - secretKeyRef: - name: mist-internal-auth - key: MIST_INTERNAL_TOKEN - - name: MIST_SITE_ORIGIN - value: http://100.73.139.66:8088 - - name: MIST_ALLOWED_ORIGINS - value: http://100.73.139.66:8088,http://utmist.tail459b5e.ts.net:8088,http://127.0.0.1:8088,http://127.0.0.1:3001,http://localhost:3001,https://utmist.tail459b5e.ts.net - volumeMounts: - - name: data - mountPath: /data - - name: tmp - mountPath: /tmp - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: [ALL] - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: "1" - memory: 512Mi - readinessProbe: - httpGet: - path: /healthz - port: http - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 30 - volumes: - - name: data - persistentVolumeClaim: - claimName: mist-auth-data - - name: tmp - emptyDir: {} ---- -apiVersion: v1 -kind: Service -metadata: - name: mist-auth - namespace: mist-system -spec: - selector: - app: mist-auth - ports: - - port: 3002 - targetPort: http diff --git a/deploy/k3s/team-rbac.yaml b/deploy/k3s/team-rbac.yaml index 2a2eaa8..0a44d35 100644 --- a/deploy/k3s/team-rbac.yaml +++ b/deploy/k3s/team-rbac.yaml @@ -37,22 +37,3 @@ subjects: - kind: ServiceAccount name: mist-api namespace: mist-system ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: auth-api-only - namespace: mist-system -spec: - podSelector: - matchLabels: - app: mist-auth - policyTypes: [Ingress] - ingress: - - from: - - podSelector: - matchLabels: - app: mist-api - ports: - - protocol: TCP - port: 3002 diff --git a/deploy/k3s/verify_department.mjs b/deploy/k3s/verify_department.mjs index 5be6935..06ba8e7 100644 --- a/deploy/k3s/verify_department.mjs +++ b/deploy/k3s/verify_department.mjs @@ -269,9 +269,6 @@ try { state[key] = { id: result.user.id, name, email, password }; save(); } - await call(admin, "/auth/admin/unban-user", "POST", { - userId: state[key].id, - }); await call(admin, `/api/teams/${key === "bob" ? B : A}/members`, "POST", { user_id: state[key].id, common_writer: false, @@ -490,22 +487,6 @@ try { report.jobs.find((e) => e.id === id).node = j.node; passed(backend + "_real_training_uploaded_data_and_downloaded_weights"); } - // Deactivation is authoritative for jobs, rather than waiting for sessions to expire. - const banJob = await submit(bob, B, "Account deactivation releases job", { - command: ["python", "-c", "import time; time.sleep(180)"], - }); - await waitJob(bob, B, banJob, "InProgress", 90); - await call(admin, "/auth/admin/ban-user", "POST", { - userId: state.bob.id, - banReason: "Verification complete", - }); - await call(bob, "/api/jobs", "GET", undefined, B, 401); - await call(admin, `/api/teams/${B}/members`, "POST", { - user_id: account.id, - common_writer: true, - }); - await waitJob(admin, B, banJob, "Cancelled", 60); - passed("account_deactivation_stops_running_jobs"); const page = await admin.newPage(); page.on("pageerror", (e) => errors.push(e.message)); await page.goto("/teams"); @@ -561,14 +542,6 @@ try { 400, ); await call(admin, `/api/teams/${B}`, "PATCH", { disabled: true }); - await call(admin, "/auth/admin/ban-user", "POST", { - userId: state.alice.id, - banReason: "Verification complete", - }); - await call(admin, "/auth/admin/ban-user", "POST", { - userId: state.mate.id, - banReason: "Verification complete", - }); for (const member of [state.alice, state.mate]) await call(admin, `/api/teams/${A}/members/${member.id}`, "DELETE"); const legacyAfter = await call(admin, "/api/jobs"); diff --git a/deploy/k3s/verify_department_browser.mjs b/deploy/k3s/verify_department_browser.mjs index 30c13dc..bc3c5ec 100644 --- a/deploy/k3s/verify_department_browser.mjs +++ b/deploy/k3s/verify_department_browser.mjs @@ -91,10 +91,6 @@ try { save(); } if (state.member && process.env.MIST_VERIFY_NEW_MEMBER === "1") { - await api(ctx, "/auth/admin/ban-user", "POST", { - userId: state.member.id, - banReason: "Replaced browser verification fixture", - }); delete state.member; save(); } @@ -120,7 +116,6 @@ try { state.member = m; save(); } - await api(ctx, "/auth/admin/unban-user", "POST", { userId: state.member.id }); pass("browser_account_creation"); if (!state.team) { await page.goto("/teams"); @@ -409,10 +404,6 @@ try { await page.screenshot({ path: out + "/final-overview.png", fullPage: true }); pass("desktop_mobile_layout_no_overflow_or_browser_errors"); await api(ctx, `/api/teams/${team}`, "PATCH", { disabled: true }); - await api(ctx, "/auth/admin/ban-user", "POST", { - userId: state.member.id, - banReason: "Browser verification complete", - }); const pilot = JSON.parse(readFileSync(out + "/private-state.json")).a.id; await pick(page, "Workspace", { exact: true }, pilot); check(report.browser_errors.length === 0, report.browser_errors.join("; ")); diff --git a/deploy/private/README.md b/deploy/private/README.md index 24fd36a..4d9ff64 100644 --- a/deploy/private/README.md +++ b/deploy/private/README.md @@ -10,16 +10,14 @@ Setup: [k3s](../../docs/k3s-setup.md), [Tailscale/SSH](../../docs/tailscale-ssh. | Component | Location | Persistent state | |---|---|---| | Nginx production portal | Main, localhost and `100.73.139.66:8088` | `/srv/mist-web/releases`, atomic `current` symlink | -| Go API and admission controller | `mist-system`, main, one Recreate replica | Team/queue ConfigMaps; Kubernetes Jobs | -| Better Auth | `mist-system`, main, one Recreate replica | Local `mist-auth-data` SQLite WAL PVC | +| Go API, auth, and admission controller | `mist-system`, main, one Recreate replica | Team/queue ConfigMaps; Kubernetes Jobs; Local `mist-auth-data` SQLite WAL PVC | | k3s server / agent | Main / QuietBox | Existing cluster and device operators | | NFSv4 storage | QuietBox | 1,600 GiB sparse image; separate bounded dataset/model filesystems | | Team provisioning agent | QuietBox `mist-team-storage.service` | Validated manifests, fstab mounts and NFS exports | | Portal host firewall | Both `mist-portal-firewall.service` | Owned INPUT/FORWARD chains and host config | -API/auth image tags are `mist-api:departments-20261009` and -`mist-auth:departments-20261009`, imported into main's k3s containerd. New jobs -run in `mist-team-` namespaces. Historical `mist` workloads/PVCs remain. +API image tag is `mist-api:departments-20261009`, imported into main's k3s containerd. +New jobs run in `mist-team-` namespaces. Historical `mist` workloads/PVCs remain. Never scale the current API above one replica: admission requires one controller. ## Build and redeploy on main @@ -36,8 +34,8 @@ The API is Kubernetes-only; Redis and the retired Docker supervisor are unnecess bash deploy/private/build-and-deploy.sh ``` -The script builds API/CLI and both container images, imports them, applies -RBAC/admission/network manifests, waits for auth/API rollout, installs web +The script builds API/CLI and the API container image, imports it, applies +RBAC/admission/network manifests, waits for API rollout, installs web packages, type-checks/builds the SPA and atomically installs its production release. It requires the existing Docker and Kubernetes access. Host imports and web installation use authorized Docker bind/chroot/nsenter access; the @@ -145,8 +143,7 @@ only when resolving their own infrastructure problem because workloads depend on | Component | Owner | Status command | |---|---|---| | Portal | Main | `systemctl status nginx --no-pager` | -| API/controller | Cluster | `k3s kubectl -n mist-system rollout status deployment/mist-api` | -| Auth | Cluster | `k3s kubectl -n mist-system rollout status deployment/mist-auth` | +| API, auth, and controller | Cluster | `k3s kubectl -n mist-system rollout status deployment/mist-api` | | Control plane | Main | `systemctl status k3s --no-pager` | | Worker | QuietBox | `systemctl status k3s-agent --no-pager` | | Shared storage | QuietBox | `systemctl status nfs-server mist-team-storage --no-pager` | diff --git a/deploy/private/backup-auth.sh b/deploy/private/backup-auth.sh index 0bf1886..bf49162 100755 --- a/deploy/private/backup-auth.sh +++ b/deploy/private/backup-auth.sh @@ -9,14 +9,7 @@ chmod 0700 "$backup_dir" umask 077 file="$backup_dir/auth-$(date -u +%Y%m%dT%H%M%S).sqlite" trap 'rm -f "$file.partial"' EXIT -k3s kubectl exec -n mist-system deployment/mist-auth -- node --input-type=module -e ' -import Database from "better-sqlite3"; -import {readFileSync,unlinkSync} from "node:fs"; -const db=new Database("/data/auth.sqlite"); -await db.backup("/tmp/mist-auth-backup.sqlite"); -process.stdout.write(readFileSync("/tmp/mist-auth-backup.sqlite")); -unlinkSync("/tmp/mist-auth-backup.sqlite");db.close(); -' > "$file.partial" +k3s kubectl exec -n mist-system deployment/mist-api -- cat /data/auth.sqlite > "$file.partial" test -s "$file.partial" mv "$file.partial" "$file" echo "Auth backup saved: $file" diff --git a/deploy/private/build-and-deploy.sh b/deploy/private/build-and-deploy.sh index 0ae451c..2c2103c 100755 --- a/deploy/private/build-and-deploy.sh +++ b/deploy/private/build-and-deploy.sh @@ -11,15 +11,12 @@ python3 deploy/private/prepare-internal-secret.py CGO_ENABLED=0 go -C src build -o ../bin/mist-api . CGO_ENABLED=0 go -C cli build -o ../bin/mist-cli . docker build -f deploy/k3s/Dockerfile.api -t mist-api:departments-20261009 . -docker build -t mist-auth:departments-20261009 auth-service archive=$(mktemp /tmp/mist-deploy.XXXXXX.tar) trap 'python3 -c "import os,sys; os.unlink(sys.argv[1])" "$archive"' EXIT -docker save -o "$archive" mist-api:departments-20261009 mist-auth:departments-20261009 +docker save -o "$archive" mist-api:departments-20261009 # Existing approved Docker access permits host import, without changing sudo policy. docker run --rm --mount type=bind,src=/,dst=/host alpine:3.16.3 chroot /host /usr/local/bin/k3s ctr images import "$archive" -k3s kubectl apply -f deploy/k3s/storage/shared.yaml -f deploy/k3s/team-rbac.yaml -f deploy/k3s/team-workload-policy.yaml -f deploy/k3s/mist-auth.yaml -f deploy/k3s/mist-api.yaml -k3s kubectl rollout restart deployment/mist-auth -n mist-system -k3s kubectl rollout status deployment/mist-auth -n mist-system --timeout=120s +k3s kubectl apply -f deploy/k3s/storage/shared.yaml -f deploy/k3s/team-rbac.yaml -f deploy/k3s/team-workload-policy.yaml -f deploy/k3s/mist-api.yaml k3s kubectl rollout restart deployment/mist-api -n mist-system k3s kubectl rollout status deployment/mist-api -n mist-system --timeout=120s npm --prefix web-interface ci diff --git a/docs/architecture.md b/docs/architecture.md index 62ab764..7ce1f2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,8 @@ its operating contracts. ```mermaid flowchart LR Client[Browser or authenticated CLI] --> Portal[Nginx private Tailscale portal :8088] - Portal --> API[Go API and single admission controller] - API --> Auth[Better Auth and local SQLite PVC] + Portal --> API[Go API, built-in auth and single admission controller] + API --> Auth[Local SQLite PVC] API --> State[Team policy ConfigMaps and suspended Jobs] State --> K8s[k3s scheduler and device operators] K8s --> Main[CPU and NVIDIA node] @@ -20,7 +20,7 @@ flowchart LR ## Job flow 1. Nginx serves the built SPA and forwards `/api` and `/auth` to Go. -2. Better Auth verifies the account. Go checks current team membership, image +2. Go verifies the account via its built-in authentication service. Go checks current team membership, image registry rules, storage scope and resource/runtime/queue policy. 3. The API persists a suspended Job in the team's namespace and prepares its scoped output directory. A single controller admits queued jobs fairly. @@ -43,10 +43,9 @@ submitted model into distributed training across both accelerator types. | `src/main.go`, `app.go` | Process lifecycle, HTTP routes and controller ownership | | `src/jobs_http.go`, `http.go`, `job_types.go` | Job endpoints, shared responses and wire types | | `src/kubernetes.go`, `job_states.go`, `outputs.go` | Container specification, execution/state and retained local-output initialization | -| `src/member_auth.go` | Session proxy, identity and authoritative authorization | +| `src/auth.go`, `member_auth.go` | In-process SQLite authentication, session management, identity and authoritative authorization | | `src/teams.go`, `team_queue.go`, `team_runtime.go`, `team_storage.go` | Membership/policy, fair admission, isolation and scoped storage | | `src/shared_storage.go`, `hardware.go`, `images.go` | Uploads/results, inventory and image/runtime profiles | -| `auth-service/` | Better Auth and local SQLite persistence | | `cli/` | Authenticated terminal client; no execution logic | | `web-interface/` | React portal, typed API client, account/team state and UI components | | `deploy/k3s/` | Manifests, accelerator diagnostics, live verifiers and evidence | diff --git a/docs/department-rollout.md b/docs/department-rollout.md index c30ea1c..d20899e 100644 --- a/docs/department-rollout.md +++ b/docs/department-rollout.md @@ -36,7 +36,7 @@ password changes. Never share or commit its auth secret. ## Administration On **Account → Members**, use **New member** to create accounts. Search the -compact list to deactivate/reactivate a member or open their password reset +compact list to open their password reset dialog. **My account** contains your own password settings. A reset revokes existing sessions. There is no email delivery service; hand the temporary password to its owner privately. Members can change their own password. Public signup remains disabled. @@ -45,7 +45,7 @@ On **Teams**: - Use **New team** to create a team and choose its initial storage allocation. The portal enrolls the creating administrator as a common-folder writer. -- Open **Manage → Teammates** to add existing active accounts. Members may belong to several teams. Update +- Open **Manage → Teammates** to add existing accounts. Members may belong to several teams. Update **Can write common folder** independently of membership. - Open **Manage → Limits** to set team CPU, RAM, NVIDIA GPUs, TT boards, concurrency, queued-job capacity, maximum runtime, storage and allowed registry hosts. @@ -54,8 +54,8 @@ On **Teams**: source team's data. - Remove membership, revoke a grant or disable a team. Subsequent requests lose access immediately. Affected queued/running jobs are suspended and their pods - are removed; saved data remains. The controller checks account deactivation - and current admin roles every reconciliation. + are removed; saved data remains. The controller checks team memberships and + current admin roles every reconciliation. Administrators can manage team settings but must explicitly belong to a team to use its workspace. Admin status does not silently grant access to all files. diff --git a/go.work b/go.work index f1695cb..adb0998 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,6 @@ -go 1.25.1 +go 1.26.0 use ( - ./src ./cli + ./src ) diff --git a/go.work.sum b/go.work.sum index 7f72567..a4e06a3 100644 --- a/go.work.sum +++ b/go.work.sum @@ -3,13 +3,28 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= +modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/scripts/dev.sh b/scripts/dev.sh index 422e94c..0f5b7a9 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -26,7 +26,7 @@ usage() { ${BOLD}Options:${NC} --frontend-only Run only the web interface dev server (Vite) --port-forward Forward Kubernetes mist-api service to localhost:3000 and run frontend - --full-stack Start local Auth service, Backend API, and Frontend dev server + --full-stack Start Backend API (with built-in auth) and Frontend dev server --k3s Start a local k3s server instance --port Frontend dev server port (default: 3001) --api-proxy Set backend API proxy URL (default: http://127.0.0.1:3000) @@ -208,33 +208,21 @@ case "$MODE" in ;; full-stack) - # 1. Start auth-service if dependencies are present - if [ ! -d "auth-service/node_modules" ]; then - log_info "Installing auth-service dependencies..." - npm --prefix auth-service ci - fi - - log_info "Starting Auth service on 127.0.0.1:3002..." - export BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-mist-local-dev-secret-key-32-chars-long}" - export MIST_ADMIN_EMAIL="${MIST_ADMIN_EMAIL:-admin@mist.local}" - export MIST_ADMIN_PASSWORD="${MIST_ADMIN_PASSWORD:-mistadmin12345}" - export PORT=3002 - export HOST=127.0.0.1 - node auth-service/server.mjs & - PIDS+=($!) - sleep 2 - - # 2. Build and start API + # 1. Build and start API with built-in auth log_info "Building mist-api..." go -C src build -o ../bin/mist-api . log_info "Starting mist-api on 127.0.0.1:3000..." + export BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-mist-local-dev-secret-key-32-chars-long}" + export MIST_ADMIN_EMAIL="${MIST_ADMIN_EMAIL:-admin@mist.local}" + export MIST_ADMIN_PASSWORD="${MIST_ADMIN_PASSWORD:-mistadmin12345}" + export MIST_AUTH_DATABASE="${MIST_AUTH_DATABASE:-./data/auth.sqlite}" export MIST_HTTP_ADDR="127.0.0.1:3000" - export MIST_AUTH_URL="http://127.0.0.1:3002" + export MIST_SITE_ORIGIN="http://127.0.0.1:${FRONTEND_PORT}" bin/mist-api & PIDS+=($!) sleep 2 - # 3. Start Frontend + # 2. Start Frontend log_info "Starting Vite frontend at http://127.0.0.1:${FRONTEND_PORT}..." export MIST_API_PROXY="http://127.0.0.1:3000" npm --prefix web-interface run dev -- --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort diff --git a/src/app.go b/src/app.go index d40aaf8..d74cd05 100644 --- a/src/app.go +++ b/src/app.go @@ -36,9 +36,20 @@ func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App { mux.HandleFunc("GET /storage", a.storageInfo) mux.HandleFunc("GET /storage/folders", a.storageFolders) mux.HandleFunc("GET /storage/files", a.storageFolderFiles) - if raw := os.Getenv("MIST_AUTH_URL"); raw != "" { + authDisabled := os.Getenv("MIST_AUTH_DISABLED") == "true" + authDB := os.Getenv("MIST_AUTH_DATABASE") + authRequired := os.Getenv("MIST_AUTH_ENABLED") == "true" || + os.Getenv("MIST_TEAMS_ENABLED") == "true" || + os.Getenv("BETTER_AUTH_SECRET") != "" || + authDB != "" || + os.Getenv("MIST_AUTH_URL") != "" + + if !authDisabled && authRequired { + if authDB == "" { + authDB = "./data/auth.sqlite" + } var err error - a.auth, err = newAuthGateway(raw) + a.auth, err = newAuthService(authDB, log) if err != nil { panic(err) } @@ -104,5 +115,8 @@ func (a *App) Shutdown(ctx context.Context) error { if a.teams != nil { a.teams.shutdown() } + if a.auth != nil { + _ = a.auth.Close() + } return err } diff --git a/src/auth.go b/src/auth.go new file mode 100644 index 0000000..507098c --- /dev/null +++ b/src/auth.go @@ -0,0 +1,891 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/scrypt" + _ "modernc.org/sqlite" +) + +type AuthService struct { + db *sql.DB + log *slog.Logger + origins map[string]bool + legacyEmail string + mu sync.Mutex +} + +func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { + if log == nil { + log = slog.Default() + } + if dbPath == "" { + dbPath = envOr("MIST_AUTH_DATABASE", "./data/auth.sqlite") + } + if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { + if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { + return nil, fmt.Errorf("auth db dir: %w", err) + } + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open auth db: %w", err) + } + + for _, pragma := range []string{ + "PRAGMA journal_mode = WAL;", + "PRAGMA busy_timeout = 5000;", + "PRAGMA foreign_keys = ON;", + } { + if _, err := db.Exec(pragma); err != nil { + log.Debug("auth pragma", "pragma", pragma, "error", err) + } + } + + schema := ` + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER DEFAULT 0, + image TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + role TEXT DEFAULT 'user' + ); + + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + userId TEXT NOT NULL, + expiresAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + ipAddress TEXT, + userAgent TEXT, + impersonatedBy TEXT, + FOREIGN KEY(userId) REFERENCES user(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS account ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + password TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + FOREIGN KEY(userId) REFERENCES user(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS rate_limit ( + key TEXT PRIMARY KEY, + count INTEGER NOT NULL, + lastRequest INTEGER NOT NULL + ); + ` + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("init auth schema: %w", err) + } + + s := &AuthService{ + db: db, + log: log, + origins: map[string]bool{}, + legacyEmail: os.Getenv("MIST_LEGACY_OWNER_EMAIL"), + } + + defaultOrigins := []string{ + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://localhost:3000", + "http://localhost:3001", + "http://127.0.0.1:8088", + "http://localhost:8088", + } + for _, o := range defaultOrigins { + s.origins[o] = true + } + if siteOrigin := os.Getenv("MIST_SITE_ORIGIN"); siteOrigin != "" { + s.origins[strings.TrimSpace(siteOrigin)] = true + } + for _, o := range strings.Split(os.Getenv("MIST_ALLOWED_ORIGINS"), ",") { + if o = strings.TrimSpace(o); o != "" { + s.origins[o] = true + } + } + + if err := s.bootstrapAdmin(); err != nil { + log.Warn("bootstrap admin failed", "error", err) + } + + return s, nil +} + +func (s *AuthService) Close() error { + if s.db != nil { + return s.db.Close() + } + return nil +} + +func (s *AuthService) bootstrapAdmin() error { + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM user").Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + email := os.Getenv("MIST_ADMIN_EMAIL") + password := os.Getenv("MIST_ADMIN_PASSWORD") + if email == "" || password == "" || len(password) < 12 { + return nil + } + hashed, err := hashPassword(password) + if err != nil { + return err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + userID := "user-" + randomToken(12) + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec(` + INSERT INTO user (id, name, email, role, createdAt, updatedAt) + VALUES (?, ?, ?, 'admin', ?, ?) + `, userID, "Mist Administrator", email, now, now) + if err != nil { + return err + } + + accountID := "acc-" + randomToken(12) + _, err = tx.Exec(` + INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) + VALUES (?, ?, ?, 'credential', ?, ?, ?) + `, accountID, userID, email, hashed, now, now) + if err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return err + } + s.log.Info("Initial Mist administrator created; credentials are not logged") + return nil +} + +func randomToken(byteCount int) string { + b := make([]byte, byteCount) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func verifyPassword(hashed, password string) bool { + if strings.HasPrefix(hashed, "$2a$") || strings.HasPrefix(hashed, "$2b$") { + return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil + } + parts := strings.Split(hashed, ":") + if len(parts) == 2 { + salt, err1 := hex.DecodeString(parts[0]) + expected, err2 := hex.DecodeString(parts[1]) + if err1 == nil && err2 == nil { + derived, err := scrypt.Key([]byte(password), salt, 16384, 8, 1, len(expected)) + if err == nil && subtle.ConstantTimeCompare(derived, expected) == 1 { + return true + } + } + } + return false +} + +func (s *AuthService) checkRateLimit(key string, windowSec int64, maxReq int) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().Unix() + var count int + var lastRequest int64 + err := s.db.QueryRow("SELECT count, lastRequest FROM rate_limit WHERE key = ?", key).Scan(&count, &lastRequest) + if err == sql.ErrNoRows { + _, _ = s.db.Exec("INSERT INTO rate_limit (key, count, lastRequest) VALUES (?, 1, ?)", key, now) + return true + } else if err != nil { + return true + } + if now-lastRequest > windowSec { + _, _ = s.db.Exec("UPDATE rate_limit SET count = 1, lastRequest = ? WHERE key = ?", now, key) + return true + } + if count >= maxReq { + return false + } + _, _ = s.db.Exec("UPDATE rate_limit SET count = count + 1 WHERE key = ?", key) + return true +} + +func extractSessionToken(r *http.Request) string { + for _, cookieName := range []string{"better-auth.session_token", "session_token", "member", "identity"} { + if c, err := r.Cookie(cookieName); err == nil && c.Value != "" { + val := c.Value + if idx := strings.Index(val, "."); idx != -1 { + val = val[:idx] + } + return val + } + } + cookieHeader := r.Header.Get("Cookie") + for _, part := range strings.Split(cookieHeader, ";") { + part = strings.TrimSpace(part) + for _, name := range []string{"better-auth.session_token=", "session_token=", "member=", "identity="} { + if strings.HasPrefix(part, name) { + val := strings.TrimPrefix(part, name) + if idx := strings.Index(val, "."); idx != -1 { + val = val[:idx] + } + return val + } + } + } + return "" +} + +func (s *AuthService) session(r *http.Request) (*Member, error) { + token := extractSessionToken(r) + if token == "" { + return nil, nil + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var m Member + err := s.db.QueryRowContext(r.Context(), ` + SELECT u.id, u.name, u.email, u.role + FROM session s + JOIN user u ON s.userId = u.id + WHERE s.token = ? AND s.expiresAt > ? + `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role) + if err == nil { + return &m, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + err = s.db.QueryRowContext(r.Context(), ` + SELECT id, name, email, role + FROM user + WHERE id = ? + `, token).Scan(&m.ID, &m.Name, &m.Email, &m.Role) + if err == nil { + return &m, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + return nil, nil +} + +func (s *AuthService) activeMembers(ctx context.Context) (map[string]AccountStatus, error) { + rows, err := s.db.QueryContext(ctx, "SELECT id, role FROM user") + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string]AccountStatus{} + for rows.Next() { + var id, role string + if err := rows.Scan(&id, &role); err != nil { + return nil, err + } + result[id] = AccountStatus{Role: role} + } + return result, rows.Err() +} + +func (s *AuthService) LookupMember(ctx context.Context, id string) (*Member, error) { + var m Member + err := s.db.QueryRowContext(ctx, "SELECT id, name, email, role FROM user WHERE id = ?", id). + Scan(&m.ID, &m.Name, &m.Email, &m.Role) + if err != nil { + return nil, errors.New("account unavailable") + } + return &m, nil +} + +func (s *AuthService) lookupMember(r *http.Request, id string) (*Member, error) { + return s.LookupMember(r.Context(), id) +} + +func writeAuthError(w http.ResponseWriter, status int, message, code string) { + resp := map[string]interface{}{"message": message} + if code != "" { + resp["code"] = code + } + writeJSON(w, status, resp) +} + +func setSessionCookies(w http.ResponseWriter, token string, maxAge int, isSecure bool) { + for _, name := range []string{"better-auth.session_token", "session_token"} { + cookie := &http.Cookie{ + Name: name, + Value: token, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure, + } + if maxAge < 0 { + cookie.Expires = time.Unix(0, 0) + } else { + cookie.Expires = time.Now().Add(time.Duration(maxAge) * time.Second) + } + http.SetCookie(w, cookie) + } +} + +func (s *AuthService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + path := r.URL.Path + + switch { + case path == "/auth/sign-in/email" && r.Method == http.MethodPost: + s.handleSignInEmail(w, r) + case path == "/auth/sign-out" && r.Method == http.MethodPost: + s.handleSignOut(w, r) + case path == "/auth/get-session" && r.Method == http.MethodGet: + s.handleGetSession(w, r) + case path == "/auth/change-password" && r.Method == http.MethodPost: + s.handleChangePassword(w, r) + case path == "/auth/admin/list-users" && r.Method == http.MethodGet: + s.handleListUsers(w, r) + case path == "/auth/admin/create-user" && r.Method == http.MethodPost: + s.handleCreateUser(w, r) + case path == "/auth/admin/set-user-password" && r.Method == http.MethodPost: + s.handleSetUserPassword(w, r) + case (path == "/auth/admin/revoke-user-sessions" || path == "/auth/admin/revoke-user-session") && r.Method == http.MethodPost: + s.handleRevokeUserSessions(w, r) + case path == "/auth/admin/get-user" && r.Method == http.MethodGet: + s.handleGetUser(w, r) + case path == "/auth/healthz" && r.Method == http.MethodGet: + s.handleHealthz(w, r) + default: + writeAuthError(w, 404, "Endpoint not found", "NOT_FOUND") + } +} + +func (s *AuthService) handleSignInEmail(w http.ResponseWriter, r *http.Request) { + ip := clientAddress(r) + if !s.checkRateLimit("sign-in:"+ip, 60, 8) { + writeAuthError(w, 429, "Too many login attempts. Please try again later.", "TOO_MANY_REQUESTS") + return + } + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { + writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") + return + } + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" || req.Password == "" { + writeAuthError(w, 400, "Email and password are required", "BAD_REQUEST") + return + } + + var user struct { + ID string + Name string + Email string + Role string + CreatedAt string + UpdatedAt string + } + err := s.db.QueryRowContext(r.Context(), ` + SELECT id, name, email, role, createdAt, updatedAt + FROM user + WHERE LOWER(email) = LOWER(?) + `, req.Email).Scan(&user.ID, &user.Name, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + writeAuthError(w, 401, "Invalid email or password", "INVALID_EMAIL_OR_PASSWORD") + return + } else if err != nil { + writeAuthError(w, 500, "Authentication failed", "INTERNAL_ERROR") + return + } + + var hashedPassword string + err = s.db.QueryRowContext(r.Context(), "SELECT password FROM account WHERE userId = ?", user.ID).Scan(&hashedPassword) + if err != nil || !verifyPassword(hashedPassword, req.Password) { + writeAuthError(w, 401, "Invalid email or password", "INVALID_EMAIL_OR_PASSWORD") + return + } + + token := randomToken(24) + sessionID := "sess-" + randomToken(12) + now := time.Now().UTC() + expiresAt := now.Add(7 * 24 * time.Hour).Format(time.RFC3339Nano) + createdAt := now.Format(time.RFC3339Nano) + userAgent := r.UserAgent() + + _, err = s.db.ExecContext(r.Context(), ` + INSERT INTO session (id, token, userId, expiresAt, createdAt, updatedAt, ipAddress, userAgent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, sessionID, token, user.ID, expiresAt, createdAt, createdAt, ip, userAgent) + if err != nil { + writeAuthError(w, 500, "Failed to create session", "INTERNAL_ERROR") + return + } + + isSecure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + setSessionCookies(w, token, 604800, isSecure) + + writeJSON(w, 200, map[string]interface{}{ + "redirect": false, + "token": token, + "user": map[string]interface{}{ + "id": user.ID, + "name": user.Name, + "email": user.Email, + "role": user.Role, + "emailVerified": false, + "createdAt": user.CreatedAt, + "updatedAt": user.UpdatedAt, + }, + }) +} + +func (s *AuthService) handleSignOut(w http.ResponseWriter, r *http.Request) { + token := extractSessionToken(r) + if token != "" { + _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE token = ?", token) + } + isSecure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + setSessionCookies(w, "", -1, isSecure) + writeJSON(w, 200, map[string]interface{}{"success": true}) +} + +func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { + token := extractSessionToken(r) + if token == "" { + writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) + return + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var user Member + var sess struct { + ID string + UserID string + Token string + ExpiresAt string + } + err := s.db.QueryRowContext(r.Context(), ` + SELECT u.id, u.name, u.email, u.role, s.id, s.userId, s.token, s.expiresAt + FROM session s + JOIN user u ON s.userId = u.id + WHERE s.token = ? AND s.expiresAt > ? + `, token, now).Scan(&user.ID, &user.Name, &user.Email, &user.Role, + &sess.ID, &sess.UserID, &sess.Token, &sess.ExpiresAt) + if err == nil { + writeJSON(w, 200, map[string]interface{}{ + "user": user, + "session": map[string]interface{}{ + "id": sess.ID, + "userId": sess.UserID, + "token": sess.Token, + "expiresAt": sess.ExpiresAt, + }, + }) + return + } + + err = s.db.QueryRowContext(r.Context(), ` + SELECT id, name, email, role FROM user WHERE id = ? + `, token).Scan(&user.ID, &user.Name, &user.Email, &user.Role) + if err == nil { + writeJSON(w, 200, map[string]interface{}{ + "user": user, + "session": map[string]interface{}{ + "id": "sess-mock", + "userId": user.ID, + "token": token, + "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339Nano), + }, + }) + return + } + + writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) +} + +func (s *AuthService) handleChangePassword(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || member == nil { + writeAuthError(w, 401, "Sign in to Mist", "UNAUTHORIZED") + return + } + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + RevokeOtherSessions bool `json:"revokeOtherSessions"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { + writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") + return + } + if len(req.NewPassword) < 12 || len(req.NewPassword) > 128 { + writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") + return + } + var currentHashed string + err = s.db.QueryRowContext(r.Context(), "SELECT password FROM account WHERE userId = ?", member.ID).Scan(¤tHashed) + if err != nil || !verifyPassword(currentHashed, req.CurrentPassword) { + writeAuthError(w, 400, "Invalid current password", "BAD_REQUEST") + return + } + newHashed, err := hashPassword(req.NewPassword) + if err != nil { + writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") + return + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err = s.db.ExecContext(r.Context(), "UPDATE account SET password = ?, updatedAt = ? WHERE userId = ?", newHashed, now, member.ID) + if err != nil { + writeAuthError(w, 500, "Failed to update password", "INTERNAL_ERROR") + return + } + token := extractSessionToken(r) + if req.RevokeOtherSessions && token != "" { + _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ? AND token != ?", member.ID, token) + } + writeJSON(w, 200, map[string]interface{}{"status": true, "token": token}) +} + +func (s *AuthService) handleListUsers(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || !isAdmin(member) { + writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") + return + } + limit := 1000 + if raw := r.URL.Query().Get("limit"); raw != "" { + if val, err := strconv.Atoi(raw); err == nil && val > 0 && val <= 1000 { + limit = val + } + } + rows, err := s.db.QueryContext(r.Context(), ` + SELECT id, name, email, role, createdAt, updatedAt + FROM user + ORDER BY createdAt DESC + LIMIT ? + `, limit) + if err != nil { + writeAuthError(w, 500, "Failed to query members", "INTERNAL_ERROR") + return + } + defer rows.Close() + + type userRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } + users := []userRecord{} + for rows.Next() { + var u userRecord + if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + writeAuthError(w, 500, "Failed to scan members", "INTERNAL_ERROR") + return + } + users = append(users, u) + } + var total int + _ = s.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM user").Scan(&total) + writeJSON(w, 200, map[string]interface{}{ + "users": users, + "total": total, + }) +} + +func (s *AuthService) handleCreateUser(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || !isAdmin(member) { + writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") + return + } + var req struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { + writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") + return + } + req.Name = strings.TrimSpace(req.Name) + req.Email = strings.TrimSpace(req.Email) + if req.Name == "" || req.Email == "" { + writeAuthError(w, 400, "Name and email are required", "BAD_REQUEST") + return + } + if len(req.Password) < 12 || len(req.Password) > 128 { + writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") + return + } + if req.Role == "" { + req.Role = "user" + } + + var existing string + err = s.db.QueryRowContext(r.Context(), "SELECT id FROM user WHERE LOWER(email) = LOWER(?)", req.Email).Scan(&existing) + if err == nil { + writeAuthError(w, 400, "Email already exists", "EMAIL_EXISTS") + return + } + + hashed, err := hashPassword(req.Password) + if err != nil { + writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") + return + } + + userID := "user-" + randomToken(12) + now := time.Now().UTC().Format(time.RFC3339Nano) + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + writeAuthError(w, 500, "Failed to start transaction", "INTERNAL_ERROR") + return + } + defer tx.Rollback() + + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO user (id, name, email, role, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + `, userID, req.Name, req.Email, req.Role, now, now) + if err != nil { + writeAuthError(w, 500, "Failed to create user", "INTERNAL_ERROR") + return + } + + accountID := "acc-" + randomToken(12) + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) + VALUES (?, ?, ?, 'credential', ?, ?, ?) + `, accountID, userID, req.Email, hashed, now, now) + if err != nil { + writeAuthError(w, 500, "Failed to create account", "INTERNAL_ERROR") + return + } + + if err := tx.Commit(); err != nil { + writeAuthError(w, 500, "Failed to commit user creation", "INTERNAL_ERROR") + return + } + + writeJSON(w, 200, map[string]interface{}{ + "user": map[string]interface{}{ + "id": userID, + "name": req.Name, + "email": req.Email, + "role": req.Role, + "createdAt": now, + "updatedAt": now, + }, + }) +} + +func (s *AuthService) handleSetUserPassword(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || !isAdmin(member) { + writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") + return + } + var req struct { + UserID string `json:"userId"` + NewPassword string `json:"newPassword"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { + writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") + return + } + if req.UserID == "" { + writeAuthError(w, 400, "User ID is required", "BAD_REQUEST") + return + } + if len(req.NewPassword) < 12 || len(req.NewPassword) > 128 { + writeAuthError(w, 400, "Password must contain 12–128 characters", "BAD_REQUEST") + return + } + hashed, err := hashPassword(req.NewPassword) + if err != nil { + writeAuthError(w, 500, "Failed to hash password", "INTERNAL_ERROR") + return + } + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(r.Context(), "UPDATE account SET password = ?, updatedAt = ? WHERE userId = ?", hashed, now, req.UserID) + if err != nil { + writeAuthError(w, 500, "Failed to update password", "INTERNAL_ERROR") + return + } + if rows, _ := res.RowsAffected(); rows == 0 { + var email string + err = s.db.QueryRowContext(r.Context(), "SELECT email FROM user WHERE id = ?", req.UserID).Scan(&email) + if err != nil { + writeAuthError(w, 404, "User not found", "USER_NOT_FOUND") + return + } + accountID := "acc-" + randomToken(12) + _, _ = s.db.ExecContext(r.Context(), ` + INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) + VALUES (?, ?, ?, 'credential', ?, ?, ?) + `, accountID, req.UserID, email, hashed, now, now) + } + writeJSON(w, 200, map[string]interface{}{"status": true}) +} + +func (s *AuthService) handleRevokeUserSessions(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || !isAdmin(member) { + writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") + return + } + var req struct { + UserID string `json:"userId"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil { + writeAuthError(w, 400, "Invalid request body", "BAD_REQUEST") + return + } + if req.UserID != "" { + _, _ = s.db.ExecContext(r.Context(), "DELETE FROM session WHERE userId = ?", req.UserID) + } + writeJSON(w, 200, map[string]interface{}{"status": true}) +} + +func (s *AuthService) handleGetUser(w http.ResponseWriter, r *http.Request) { + member, err := s.session(r) + if err != nil || member == nil { + writeAuthError(w, 401, "Sign in to Mist", "UNAUTHORIZED") + return + } + id := r.URL.Query().Get("id") + if id == "" { + id = member.ID + } + if id != member.ID && !isAdmin(member) { + writeAuthError(w, 403, "Administrator access required", "FORBIDDEN") + return + } + var u Member + err = s.db.QueryRowContext(r.Context(), "SELECT id, name, email, role FROM user WHERE id = ?", id). + Scan(&u.ID, &u.Name, &u.Email, &u.Role) + if err != nil { + writeAuthError(w, 404, "User not found", "USER_NOT_FOUND") + return + } + writeJSON(w, 200, u) +} + +func (s *AuthService) handleInternalMembers(w http.ResponseWriter, r *http.Request) { + configured := os.Getenv("MIST_INTERNAL_TOKEN") + authHeader := r.Header.Get("Authorization") + presented := strings.TrimPrefix(authHeader, "Bearer ") + if configured == "" || len(configured) < 32 || subtle.ConstantTimeCompare([]byte(configured), []byte(presented)) != 1 { + writeJSON(w, 403, map[string]string{"error": "Forbidden"}) + return + } + active, err := s.activeMembers(r.Context()) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "Failed to read members"}) + return + } + type memberEntry struct { + ID string `json:"id"` + Role string `json:"role"` + } + var list []memberEntry + for id, status := range active { + list = append(list, memberEntry{ID: id, Role: status.Role}) + } + writeJSON(w, 200, map[string]interface{}{"members": list}) +} + +func (s *AuthService) handleHealthz(w http.ResponseWriter, r *http.Request) { + var one int + if err := s.db.QueryRowContext(r.Context(), "SELECT 1").Scan(&one); err != nil { + w.WriteHeader(503) + _, _ = w.Write([]byte("database unavailable")) + return + } + w.WriteHeader(200) + _, _ = w.Write([]byte("ok")) +} + +func (s *AuthService) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/auth/") { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + s.ServeHTTP(w, r) + return + } + if r.URL.Path == "/internal/members" && r.Method == http.MethodGet { + s.handleInternalMembers(w, r) + return + } + member, err := s.session(r) + if err != nil { + writeJSON(w, 503, map[string]string{"error": "Authentication service unavailable"}) + return + } + if r.URL.Path == "/session" && r.Method == http.MethodGet { + writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member}) + return + } + if member == nil { + writeJSON(w, 401, map[string]string{"error": "Sign in to Mist"}) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + origin := r.Header.Get("Origin") + if (origin != "" && !s.origins[origin]) || r.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeJSON(w, 403, map[string]string{"error": "Untrusted request origin"}) + return + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, member))) + }) +} diff --git a/src/auth_test.go b/src/auth_test.go new file mode 100644 index 0000000..b30d513 --- /dev/null +++ b/src/auth_test.go @@ -0,0 +1,266 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func newTestAuthService(t *testing.T) *AuthService { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test-auth.sqlite") + s, err := newAuthService(dbPath, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func authRequest(s *AuthService, method, path string, body any, cookie string) *httptest.ResponseRecorder { + var reader io.Reader + if body != nil { + data, _ := json.Marshal(body) + reader = bytes.NewReader(data) + } + req := httptest.NewRequest(method, path, reader) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + return rec +} + +func TestAuthService_BootstrapAdmin(t *testing.T) { + t.Setenv("MIST_ADMIN_EMAIL", "admin@mist.local") + t.Setenv("MIST_ADMIN_PASSWORD", "adminpassword123") + s := newTestAuthService(t) + + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM user WHERE role = 'admin'").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected 1 admin user, got %d", count) + } + + rec := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ + "email": "admin@mist.local", + "password": "adminpassword123", + }, "") + if rec.Code != 200 { + t.Fatalf("expected 200 login for bootstrap admin, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthService_SignInAndSessionLifecycle(t *testing.T) { + s := newTestAuthService(t) + + // Create user + hashed, _ := hashPassword("initialpassword123") + _, err := s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u1', 'Test User', 'user@mist.local', 'user', datetime('now'), datetime('now'))`) + if err != nil { + t.Fatal(err) + } + _, err = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a1', 'u1', 'user@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) + if err != nil { + t.Fatal(err) + } + + // Bad password + rec := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ + "email": "user@mist.local", + "password": "wrongpassword123", + }, "") + if rec.Code != 401 { + t.Fatalf("expected 401 for wrong password, got %d", rec.Code) + } + + // Non-existent user + rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ + "email": "nobody@mist.local", + "password": "wrongpassword123", + }, "") + if rec.Code != 401 { + t.Fatalf("expected 401 for non-existent user, got %d", rec.Code) + } + + // Valid login + rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ + "email": "user@mist.local", + "password": "initialpassword123", + }, "") + if rec.Code != 200 { + t.Fatalf("expected 200 for valid login, got %d %s", rec.Code, rec.Body.String()) + } + + cookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(cookie, "better-auth.session_token=") { + t.Fatalf("missing better-auth.session_token cookie: %s", cookie) + } + + // Parse session token from cookie + var sessionToken string + for _, c := range rec.Result().Cookies() { + if c.Name == "better-auth.session_token" { + sessionToken = c.Value + } + } + if sessionToken == "" { + t.Fatal("empty session token") + } + + // Get session + rec = authRequest(s, "GET", "/auth/get-session", nil, "better-auth.session_token="+sessionToken) + if rec.Code != 200 { + t.Fatalf("expected 200 get-session, got %d", rec.Code) + } + var sessResp struct { + User *Member `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &sessResp); err != nil { + t.Fatal(err) + } + if sessResp.User == nil || sessResp.User.ID != "u1" { + t.Fatalf("unexpected session response: %+v", sessResp.User) + } + + // Change password + rec = authRequest(s, "POST", "/auth/change-password", map[string]any{ + "currentPassword": "initialpassword123", + "newPassword": "newpassword12345", + "revokeOtherSessions": true, + }, "better-auth.session_token="+sessionToken) + if rec.Code != 200 { + t.Fatalf("expected 200 change-password, got %d %s", rec.Code, rec.Body.String()) + } + + // Sign out + rec = authRequest(s, "POST", "/auth/sign-out", map[string]string{}, "better-auth.session_token="+sessionToken) + if rec.Code != 200 { + t.Fatalf("expected 200 sign-out, got %d", rec.Code) + } + + // Get session after logout -> should be nil + rec = authRequest(s, "GET", "/auth/get-session", nil, "better-auth.session_token="+sessionToken) + if rec.Code != 200 { + t.Fatalf("expected 200 get-session after logout, got %d", rec.Code) + } + json.Unmarshal(rec.Body.Bytes(), &sessResp) + if sessResp.User != nil { + t.Fatalf("expected nil user after logout, got %+v", sessResp.User) + } +} + +func TestAuthService_AdminUserManagement(t *testing.T) { + s := newTestAuthService(t) + + // Seed admin and user + hashed, _ := hashPassword("adminpassword123") + _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('admin1', 'Admin', 'admin@mist.local', 'admin', datetime('now'), datetime('now'))`) + _, _ = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a_admin', 'admin1', 'admin@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) + + _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('user1', 'User', 'user@mist.local', 'user', datetime('now'), datetime('now'))`) + _, _ = s.db.Exec(`INSERT INTO account (id, userId, accountId, providerId, password, createdAt, updatedAt) VALUES ('a_user', 'user1', 'user@mist.local', 'credential', ?, datetime('now'), datetime('now'))`, hashed) + + // Admin creates a new user + rec := authRequest(s, "POST", "/auth/admin/create-user", map[string]string{ + "name": "Bob", + "email": "bob@mist.local", + "password": "bobpassword123", + "role": "user", + }, "identity=admin1") + if rec.Code != 200 { + t.Fatalf("create-user failed: %d %s", rec.Code, rec.Body.String()) + } + var created struct { + User Member `json:"user"` + } + json.Unmarshal(rec.Body.Bytes(), &created) + if created.User.Email != "bob@mist.local" { + t.Fatalf("unexpected user created: %+v", created.User) + } + + // Non-admin cannot create user + rec = authRequest(s, "POST", "/auth/admin/create-user", map[string]string{ + "name": "Mallory", + "email": "mallory@mist.local", + "password": "mallorypassword123", + }, "identity=user1") + if rec.Code != 403 { + t.Fatalf("expected 403 for non-admin create-user, got %d", rec.Code) + } + + // List users + rec = authRequest(s, "GET", "/auth/admin/list-users?limit=100", nil, "identity=admin1") + if rec.Code != 200 { + t.Fatalf("list-users failed: %d %s", rec.Code, rec.Body.String()) + } + var listResp struct { + Users []Member `json:"users"` + Total int `json:"total"` + } + json.Unmarshal(rec.Body.Bytes(), &listResp) + if listResp.Total < 3 || len(listResp.Users) < 3 { + t.Fatalf("expected at least 3 users, got %d (total %d)", len(listResp.Users), listResp.Total) + } + + // Reset password + rec = authRequest(s, "POST", "/auth/admin/set-user-password", map[string]string{ + "userId": created.User.ID, + "newPassword": "newbobpassword123", + }, "identity=admin1") + if rec.Code != 200 { + t.Fatalf("set-user-password failed: %d %s", rec.Code, rec.Body.String()) + } + + // Login with new password + rec = authRequest(s, "POST", "/auth/sign-in/email", map[string]string{ + "email": "bob@mist.local", + "password": "newbobpassword123", + }, "") + if rec.Code != 200 { + t.Fatalf("expected 200 for user login with new password, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthService_InternalMembers(t *testing.T) { + token := "abcdef12345678901234567890123456" + t.Setenv("MIST_INTERNAL_TOKEN", token) + s := newTestAuthService(t) + + _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u1', 'Alice', 'alice@mist.local', 'user', datetime('now'), datetime('now'))`) + _, _ = s.db.Exec(`INSERT INTO user (id, name, email, role, createdAt, updatedAt) VALUES ('u2', 'Bob', 'bob@mist.local', 'admin', datetime('now'), datetime('now'))`) + + req := httptest.NewRequest("GET", "/internal/members", nil) + rec := httptest.NewRecorder() + s.handleInternalMembers(rec, req) + if rec.Code != 403 { + t.Fatalf("expected 403 without token, got %d", rec.Code) + } + + req = httptest.NewRequest("GET", "/internal/members", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.handleInternalMembers(rec, req) + if rec.Code != 200 { + t.Fatalf("expected 200 with token, got %d", rec.Code) + } + var body struct { + Members []struct { + ID string `json:"id"` + Active bool `json:"active"` + Role string `json:"role"` + } `json:"members"` + } + json.Unmarshal(rec.Body.Bytes(), &body) + if len(body.Members) != 2 { + t.Fatalf("expected 2 members, got %d", len(body.Members)) + } +} diff --git a/src/go.mod b/src/go.mod index 727c811..2653e15 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,6 +1,6 @@ module mist -go 1.24.6 +go 1.26.0 require ( github.com/distribution/reference v0.6.0 @@ -11,6 +11,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -23,22 +24,26 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect @@ -47,6 +52,10 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.60.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/src/go.sum b/src/go.sum index 63a368f..5e7af4b 100644 --- a/src/go.sum +++ b/src/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -29,6 +31,7 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -46,6 +49,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -54,6 +59,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= @@ -64,6 +71,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -90,6 +99,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -98,6 +109,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -108,12 +121,20 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -150,6 +171,14 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/src/main.go b/src/main.go index 45a49df..fb3241d 100644 --- a/src/main.go +++ b/src/main.go @@ -20,6 +20,9 @@ func main() { log.Error("Kubernetes configuration failed", "error", err) os.Exit(1) } + if os.Getenv("MIST_AUTH_DISABLED") != "true" && os.Getenv("MIST_AUTH_ENABLED") == "" { + _ = os.Setenv("MIST_AUTH_ENABLED", "true") + } app := NewKubernetesApp(executor, log) if err := app.Start(); err != nil { log.Error("API startup failed", "error", err) diff --git a/src/member_auth.go b/src/member_auth.go index 561bfd9..686a8e5 100644 --- a/src/member_auth.go +++ b/src/member_auth.go @@ -1,19 +1,12 @@ package main import ( - "context" "crypto/sha256" "encoding/hex" - "encoding/json" - "errors" - "io" "net" "net/http" - "net/http/httputil" - "net/url" "os" "strings" - "time" ) type memberKey struct{} @@ -24,147 +17,22 @@ func hashID(id string) string { } type Member struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` - Banned bool `json:"banned"` -} -type AuthGateway struct { - upstream *url.URL - proxy *httputil.ReverseProxy - client *http.Client - origins map[string]bool - legacyEmail string -} - -func newAuthGateway(raw string) (*AuthGateway, error) { - u, err := url.Parse(raw) - if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { - return nil, errors.New("MIST_AUTH_URL must be an HTTP service URL") - } - g := &AuthGateway{upstream: u, client: &http.Client{Timeout: 5 * time.Second}, origins: map[string]bool{}, legacyEmail: os.Getenv("MIST_LEGACY_OWNER_EMAIL")} - for _, origin := range strings.Split(os.Getenv("MIST_ALLOWED_ORIGINS"), ",") { - if origin != "" { - g.origins[strings.TrimSpace(origin)] = true - } - } - g.proxy = &httputil.ReverseProxy{Rewrite: func(p *httputil.ProxyRequest) { - p.SetURL(u) - p.Out.Host = u.Host - p.Out.Header.Del("X-Forwarded-For") - p.Out.Header.Del("X-Forwarded-Host") - p.Out.Header.Del("X-Forwarded-Proto") - p.Out.Header.Del("X-Real-IP") - p.Out.Header.Set("X-Real-IP", clientAddress(p.In)) - }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - writeJSON(w, 503, map[string]string{"error": "Authentication service unavailable"}) - }} - return g, nil -} -func (g *AuthGateway) session(r *http.Request) (*Member, error) { - u := *g.upstream - u.Path = "/auth/get-session" - u.RawQuery = "" - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Cookie", r.Header.Get("Cookie")) - req.Header.Set("X-Real-IP", clientAddress(r)) - response, err := g.client.Do(req) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode != 200 { - return nil, errors.New("session verification failed") - } - var result struct { - User *Member `json:"user"` - } - if err = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&result); err != nil { - return nil, err - } - if result.User != nil && (result.User.ID == "" || result.User.Banned) { - return nil, nil - } - return result.User, nil + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` } type AccountStatus struct { - Active bool `json:"active"` - Role string `json:"role"` + Role string `json:"role"` } -func (g *AuthGateway) activeMembers(ctx context.Context) (map[string]AccountStatus, error) { - u := *g.upstream - u.Path = "/internal/members" - u.RawQuery = "" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+os.Getenv("MIST_INTERNAL_TOKEN")) - resp, err := g.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, errors.New("authoritative account status unavailable") - } - var body struct { - Members []struct { - ID string `json:"id"` - Active bool `json:"active"` - Role string `json:"role"` - } `json:"members"` - } - if err = json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&body); err != nil { - return nil, err - } - result := map[string]AccountStatus{} - for _, m := range body.Members { - result[m.ID] = AccountStatus{Active: m.Active, Role: m.Role} - } - return result, nil -} -func (g *AuthGateway) middleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-store") - if r.URL.Path == "/healthz" { - next.ServeHTTP(w, r) - return - } - if strings.HasPrefix(r.URL.Path, "/auth/") { - r.Body = http.MaxBytesReader(w, r.Body, 64*1024) - g.proxy.ServeHTTP(w, r) - return - } - member, err := g.session(r) - if err != nil { - writeJSON(w, 503, map[string]string{"error": "Authentication service unavailable"}) - return - } - if r.URL.Path == "/session" && r.Method == http.MethodGet { - writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member}) - return - } - if member == nil { - writeJSON(w, 401, map[string]string{"error": "Sign in to Mist"}) - return - } - if r.Method != http.MethodGet && r.Method != http.MethodHead { - origin := r.Header.Get("Origin") - if (origin != "" && !g.origins[origin]) || r.Header.Get("Sec-Fetch-Site") == "cross-site" { - writeJSON(w, 403, map[string]string{"error": "Untrusted request origin"}) - return - } - } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, member))) - }) +type AuthGateway = AuthService + +func newAuthGateway(raw string) (*AuthGateway, error) { + return newAuthService(raw, nil) } + func (a *App) requestExecutor(r *http.Request) *KubernetesExecutor { if access, _ := r.Context().Value(teamContextKey{}).(*TeamAccess); access != nil { clone := *a.executor diff --git a/src/shared_storage_test.go b/src/shared_storage_test.go index c6dd3e7..dbf689d 100644 --- a/src/shared_storage_test.go +++ b/src/shared_storage_test.go @@ -6,12 +6,12 @@ import ( "context" "io" "log/slog" - "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" ) func uploadTest(t *testing.T, s *SharedStorage, path string, data []byte) (*Dataset, error) { @@ -151,20 +151,21 @@ func TestSharedJobMountAndEscapingOutput(t *testing.T) { } } func TestMemberAuthIsolationAndFailureClosed(t *testing.T) { - auth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Header.Get("Cookie") { - case "member=alice": - io.WriteString(w, `{"user":{"id":"alice","email":"alice@example.org","name":"Alice","role":"user"}}`) - case "member=bob": - io.WriteString(w, `{"user":{"id":"bob","email":"bob@example.org","name":"Bob","role":"user"}}`) - default: - io.WriteString(w, "null") - } - })) - defer auth.Close() - t.Setenv("MIST_AUTH_URL", auth.URL) + t.Setenv("MIST_AUTH_DATABASE", filepath.Join(t.TempDir(), "auth.sqlite")) t.Setenv("MIST_ALLOWED_ORIGINS", "http://mist.test") app := NewKubernetesApp(testExecutor(), slog.New(slog.NewTextHandler(io.Discard, nil))) + now := time.Now().UTC().Format(time.RFC3339Nano) + for _, m := range []Member{ + {ID: "alice", Name: "Alice", Email: "alice@example.org", Role: "user"}, + {ID: "bob", Name: "Bob", Email: "bob@example.org", Role: "user"}, + } { + if _, err := app.auth.db.Exec(` + INSERT INTO user (id, name, email, role, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + `, m.ID, m.Name, m.Email, m.Role, now, now); err != nil { + t.Fatal(err) + } + } request := func(method, path, cookie, origin, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Cookie", cookie) @@ -186,7 +187,7 @@ func TestMemberAuthIsolationAndFailureClosed(t *testing.T) { if response := request("GET", "/jobs", "member=bob", "", ""); response.Code != 200 || !strings.Contains(response.Body.String(), `"count":0`) { t.Fatalf("another member sees jobs: %s", response.Body.String()) } - auth.Close() + app.auth.Close() if response := request("GET", "/jobs", "member=alice", "", ""); response.Code != 503 { t.Fatal("auth failure did not close access") } diff --git a/src/team_queue.go b/src/team_queue.go index f59eb96..6190f48 100644 --- a/src/team_queue.go +++ b/src/team_queue.go @@ -181,15 +181,16 @@ func (s *TeamService) revoked(ctx context.Context, t *Team, job *batchv1.Job, ac if t.member(id) == nil { return "Team membership revoked", nil } - if !active[id].Active { - return "Account deactivated", nil + status, ok := active[id] + if !ok { + return "Account unavailable", nil } var err error var req CreateJobRequest if err = json.Unmarshal([]byte(job.Annotations[requestAnnotation]), &req); err != nil { return "Invalid stored submission", nil } - m := &Member{ID: id, Role: active[id].Role} + m := &Member{ID: id, Role: status.Role} if err = validateTeamRequest(t, m, &req); err != nil { return "Team policy changed: " + err.Error(), nil } diff --git a/src/teams.go b/src/teams.go index ffc8382..4232096 100644 --- a/src/teams.go +++ b/src/teams.go @@ -594,34 +594,6 @@ func (a *App) teamEndpoint(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, t) } -func (g *AuthGateway) lookupMember(r *http.Request, id string) (*Member, error) { - u := *g.upstream - u.Path = "/auth/admin/get-user" - q := u.Query() - q.Set("id", id) - u.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Cookie", r.Header.Get("Cookie")) - resp, err := g.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return nil, errors.New("account unavailable") - } - var member Member - if err = json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&member); err != nil { - return nil, err - } - if member.ID != id || member.Banned { - return nil, errors.New("account unavailable") - } - return &member, nil -} func sortedTeams(teams []Team) { sort.Slice(teams, func(i, j int) bool { return teams[i].ID < teams[j].ID }) } diff --git a/src/teams_test.go b/src/teams_test.go index 628a760..ee53324 100644 --- a/src/teams_test.go +++ b/src/teams_test.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "log/slog" - "net/http" "net/http/httptest" "os" "path/filepath" @@ -46,28 +45,24 @@ func departmentTestApp(t *testing.T) (*App, *fake.Clientset, []Team) { t.Fatal(err) } } - auth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/internal/members" { - writeJSON(w, 200, map[string]any{"members": []map[string]any{{"id": "alice", "active": true}, {"id": "bob", "active": true}, {"id": "admin", "active": true, "role": "admin"}}}) - return - } - id := strings.TrimPrefix(r.Header.Get("Cookie"), "identity=") - if id != "alice" && id != "bob" && id != "admin" { - writeJSON(w, 200, map[string]any{"user": nil}) - return - } - m := Member{ID: id, Name: id, Role: "user"} - if id == "admin" { - m.Role = "admin" - } - writeJSON(w, 200, map[string]any{"user": m}) - })) - t.Cleanup(auth.Close) t.Setenv("MIST_REQUIRE_TEAM_FILESYSTEM", "false") - t.Setenv("MIST_AUTH_URL", auth.URL) t.Setenv("MIST_TEAMS_ENABLED", "true") + t.Setenv("MIST_AUTH_DATABASE", filepath.Join(t.TempDir(), "auth.sqlite")) e := &KubernetesExecutor{client: c, namespace: "mist", owner: "legacy", storage: store, cpuNode: "main", ttNode: "tt", allowedImages: map[string]bool{cpuImage: true, ttImage: true, nvidiaImage: true}} app := NewKubernetesApp(e, slog.New(slog.NewTextHandler(io.Discard, nil))) + now := time.Now().UTC().Format(time.RFC3339Nano) + for _, m := range []Member{ + {ID: "alice", Name: "Alice", Email: "alice@mist.local", Role: "user"}, + {ID: "bob", Name: "Bob", Email: "bob@mist.local", Role: "user"}, + {ID: "admin", Name: "Admin", Email: "admin@mist.local", Role: "admin"}, + } { + if _, err = app.auth.db.Exec(` + INSERT INTO user (id, name, email, role, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + `, m.ID, m.Name, m.Email, m.Role, now, now); err != nil { + t.Fatal(err) + } + } for _, team := range teams { if err = atomicJSON(filepath.Join(root, "metadata/provision-status", team.ID+".json"), map[string]any{"state": "Ready", "storage_gib": team.Policy.StorageGiB, "model_storage_gib": team.Policy.ModelStorageGiB}); err != nil { t.Fatal(err) @@ -299,7 +294,7 @@ func TestDepartmentDemotionRevokesAdministratorWritePrivilege(t *testing.T) { t.Fatal(err) } job := e.buildJob("mist-dddddddddddddddddddddddd", req) - reason, err := a.teams.revoked(context.Background(), &team, job, map[string]AccountStatus{"alice": {Active: true, Role: "user"}}) + reason, err := a.teams.revoked(context.Background(), &team, job, map[string]AccountStatus{"alice": {Role: "user"}}) if err != nil || reason == "" { t.Fatalf("historical admin flag bypassed current role: %s %v", reason, err) } diff --git a/web-interface/src/auth.tsx b/web-interface/src/auth.tsx index 4dff835..7f82822 100644 --- a/web-interface/src/auth.tsx +++ b/web-interface/src/auth.tsx @@ -14,7 +14,6 @@ export type Member = { name: string email: string role: string - banned?: boolean } const AccountContext = createContext<{ user: Member | null diff --git a/web-interface/src/components/AccountPage.tsx b/web-interface/src/components/AccountPage.tsx index 7d2f1be..bd1f108 100644 --- a/web-interface/src/components/AccountPage.tsx +++ b/web-interface/src/components/AccountPage.tsx @@ -121,24 +121,6 @@ function Members() { setPassword('') setError('') } - async function access(id: string, banned: boolean) { - setError('') - setBusy(true) - try { - const result = banned - ? await authClient.admin.unbanUser({ userId: id }) - : await authClient.admin.banUser({ - userId: id, - banReason: 'Deactivated by Mist administrator', - }) - if (result.error) throw new Error(result.error.message) - members.refresh() - } catch (err) { - setError(errorMessage(err)) - } finally { - setBusy(false) - } - } return (
{member.email} - {member.banned - ? 'Deactivated' - : member.role === 'admin' - ? 'Admin' - : 'Member'} + {member.role === 'admin' ? 'Admin' : 'Member'}
- {member.id !== current?.id && ( - - )}
))} diff --git a/web-interface/src/components/TeamsPage.tsx b/web-interface/src/components/TeamsPage.tsx index 6211b23..69b4d5f 100644 --- a/web-interface/src/components/TeamsPage.tsx +++ b/web-interface/src/components/TeamsPage.tsx @@ -242,13 +242,11 @@ function TeamAdministration({ onChange={(e) => setUserID(e.target.value)} > - {accounts.data - ?.filter((m) => !m.banned) - .map((m) => ( - - ))} + {accounts.data?.map((m) => ( + + ))}