feat(api): implement caching and logging in health check endpoint (#526)

* feat(api): implement caching and logging in health check endpoint

- Added in-memory caching for health check responses to reduce load on Postgres.
- Introduced logging for error handling in health check.
- Updated response structure to exclude error details from public responses.

feat(api): enhance OAuth consent UI and scope handling

- Improved consent UI to reflect exact requested scopes and added better user guidance.
- Updated scope handling logic to ensure least-privilege access.
- Enhanced styling for better user experience and accessibility.

chore(docker): improve security and resource management in Docker setup

- Updated Docker Compose configuration to enforce read-only file systems and resource limits.
- Added health checks and logging options for better observability.
- Introduced optional Caddy reverse proxy for TLS termination.

fix(migrations): resolve ambiguity in create_company_with_owner function

- Dropped orphaned 3-arg overload of create_company_with_owner function.
- Recreated canonical 4-arg version with cash account seeding logic.
- Ensured proper permissions for function execution in Postgres.

* feat: enhance security checks for team membership in company creation
This commit is contained in:
Mattsson
2026-05-19 17:52:11 +02:00
committed by GitHub
parent 8a6ce7093e
commit 9aced4790c
15 changed files with 1155 additions and 142 deletions
+8
View File
@@ -0,0 +1,8 @@
# Force LF on shell scripts and Docker-related files so Windows checkouts
# don't ship CRLF into Linux containers (the container would fail to exec
# a `#!/bin/sh\r` shebang).
*.sh text eol=lf
Dockerfile text eol=lf
docker-entrypoint.sh text eol=lf
docker/Caddyfile text eol=lf
docker/crontab.* text eol=lf
+51
View File
@@ -0,0 +1,51 @@
version: 2
updates:
# Base images in the root Dockerfile (node:22-alpine).
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels:
- dependencies
- docker
# Base image in the cron sidecar (alpine).
- package-ecosystem: docker
directory: /docker
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels:
- dependencies
- docker
# GitHub Actions in workflow files.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels:
- dependencies
- ci
# npm runtime + dev dependencies.
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 10
labels:
- dependencies
- npm
groups:
# Batch low-risk minor/patch bumps so the reviewer queue stays small.
minor-and-patch:
update-types:
- minor
- patch
+56 -3
View File
@@ -3,6 +3,7 @@ name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*.*.*']
env:
REGISTRY: ghcr.io
@@ -14,6 +15,10 @@ jobs:
permissions:
contents: read
packages: write
# OIDC token used by cosign keyless signing.
id-token: write
# SARIF upload to the repo's "Security" tab from the Trivy scan.
security-events: write
steps:
- uses: actions/checkout@v4
@@ -30,9 +35,14 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# On main: push `latest` + commit-sha tags.
# On v*.*.* tags: push semver tags (1.2.3, 1.2, 1) for production pinning.
tags: |
type=raw,value=latest
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -41,6 +51,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
@@ -50,5 +61,47 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
EXTENSIONS_PRESET=self-hosted
cache-from: type=gha
cache-to: type=gha,mode=max
# SBOM (software bill of materials) + SLSA provenance are attached as
# OCI attestations, queryable via `docker buildx imagetools inspect`.
provenance: mode=max
sbom: true
# Per-branch cache scope so a PR branch can't poison main's cache
# layers. Fall back to main's cache on first build of a new branch.
cache-from: |
type=gha,scope=${{ github.ref_name }}
type=gha,scope=main
cache-to: type=gha,scope=${{ github.ref_name }},mode=max
- name: Install cosign
uses: sigstore/cosign-installer@v3.7.0
- name: Sign the image (keyless OIDC)
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"
- name: Scan image with Trivy
id: trivy
uses: aquasecurity/trivy-action@0.30.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
# HIGH is blocking for an accounting application — Trivy's CRITICAL
# bucket is narrow (mostly RCE-class), and HIGH covers everything
# from auth bypass to crypto downgrades. ignore-unfixed keeps the
# gate actionable: only CVEs we can patch by rebuilding fail the
# pipeline, not upstream-pending issues we have no remediation for.
severity: CRITICAL,HIGH
exit-code: '1'
ignore-unfixed: true
format: sarif
output: trivy-results.sarif
- name: Upload Trivy results to GitHub Security tab
# if:always() so a CRITICAL finding still ends up in the Security tab
# even though the Trivy step above failed the workflow.
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
category: trivy
+84 -17
View File
@@ -44,13 +44,19 @@ Open `.env` and fill in the **required** values:
| `NEXT_PUBLIC_APP_URL` | The URL where you'll access gnubok (e.g. `https://gnubok.example.com`) |
| `CRON_SECRET` | Any random string — `openssl rand -hex 32` works |
Once `.env` is filled in, **restrict its permissions** so other users on the host can't read your service-role key or cron secret:
```bash
chmod 600 .env
```
### 3. Start
```bash
docker compose up -d
```
That's it. The app is now running at `http://localhost:3000` (or whatever port you set with `PORT`).
The app is now reachable on **loopback only** at `http://127.0.0.1:3000`. This is intentional — direct internet exposure over HTTP is not safe for an accounting app. The next section enables HTTPS.
### 4. Verify
@@ -61,6 +67,42 @@ curl http://localhost:3000/api/health
---
## Enable HTTPS (recommended)
Ship a Caddy reverse proxy alongside the app — it auto-provisions Let's Encrypt certificates and renews them forever.
### 1. Point a domain at the host
`gnubok.example.com → <your-public-ip>` (A record). Ports 80 and 443 must be reachable from the internet (Let's Encrypt's HTTP-01 challenge uses port 80).
### 2. Set `DOMAIN` in `.env`
```env
DOMAIN=gnubok.example.com
NEXT_PUBLIC_APP_URL=https://gnubok.example.com
```
### 3. Download the overlay + Caddyfile
```bash
curl -fsSLO https://raw.githubusercontent.com/gnubok/gnubok/main/docker-compose.caddy.yml
mkdir -p docker
curl -fsSL -o docker/Caddyfile \
https://raw.githubusercontent.com/gnubok/gnubok/main/docker/Caddyfile
```
### 4. Start with the overlay
```bash
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
```
Caddy obtains a cert on first boot (takes ~10 s). Visit `https://gnubok.example.com`.
If you already have nginx / a managed load balancer / Cloudflare in front, skip Caddy and point your existing proxy at `127.0.0.1:3000` — set `NEXT_PUBLIC_APP_URL` to match the public URL.
---
## Optional Extensions
The self-hosted image ships with all extensions enabled (except Enable Banking, which requires private PSD2 credentials). Each extension activates when you provide its env vars — without them, the app works normally and the feature is simply unavailable.
@@ -98,12 +140,27 @@ No env vars needed — always available.
## Updating
```bash
docker compose pull # pulls latest app image from GHCR
docker compose up -d # recreates containers if image changed
The default `IMAGE_TAG=latest` follows `main` and updates on every `docker compose pull`. For production, **pin to a specific release** so updates are deliberate:
```env
# .env
IMAGE_TAG=1.2.3
```
The `latest` tag always points to the newest build from `main`. The cron sidecar is a small Alpine image built locally — it updates automatically on `up` if you re-download `docker/cron.Dockerfile`.
Browse available tags at https://github.com/erp-mafia/gnubok/pkgs/container/gnubok. For maximum integrity, pin by digest:
```env
IMAGE_TAG=1.2.3@sha256:abcdef...
```
Apply updates:
```bash
docker compose pull
docker compose up -d
```
The cron sidecar is a small Alpine image built locally — it rebuilds automatically on `up --build` if you re-download `docker/cron.Dockerfile`. Base-image digests (node, alpine, caddy) are pinned in source; [Dependabot](.github/dependabot.yml) opens PRs weekly when upstream ships security updates.
---
@@ -137,34 +194,44 @@ The cron container waits for the app's healthcheck to pass before starting. It c
### How NEXT_PUBLIC_* injection works
The Docker image is built with placeholder values (e.g. `__NEXT_PUBLIC_SUPABASE_URL__`) baked into the JavaScript bundles. When the container starts, `docker-entrypoint.sh` replaces those placeholders with your actual env vars via `sed`. This means the same image works for any Supabase project — no rebuilding needed.
The image is built with placeholder values (e.g. `__NEXT_PUBLIC_SUPABASE_URL__`) baked into the JavaScript bundles. At container start, `docker-entrypoint.sh` runs as `root`, `sed`-substitutes the placeholders with your runtime env vars, then runs `chmod -R a-w /app/.next/static` and drops privileges with `su-exec nextjs:nodejs` before exec'ing Node. The served JS bundle is owned by `root` and read-only by the time the application starts — a runtime RCE in the Node process cannot rewrite what other users will receive.
---
## Ports
The app listens on port 3000 inside the container. To map it to a different host port:
The app listens on port 3000 inside the container. The base compose binds it to `127.0.0.1:3000` on the host — change `PORT` in `.env` to remap. To expose on all interfaces (only do this if you're putting your own reverse proxy in front), override the port binding in a local `docker-compose.override.yml`:
```env
PORT=8080
```yaml
services:
app:
ports: !override
- "${PORT:-3000}:3000"
```
Then access at `http://localhost:8080`.
---
## Reverse Proxy
For production, put the app behind a reverse proxy (nginx, Caddy, Traefik) that handles TLS. Example with Caddy:
The preferred path is the bundled Caddy overlay — see [Enable HTTPS](#enable-https-recommended). If you already run nginx, Traefik, or sit behind Cloudflare, leave the app on `127.0.0.1:3000` and point your existing proxy at it. Set `NEXT_PUBLIC_APP_URL` to the public URL.
```
gnubok.example.com {
reverse_proxy localhost:3000
Example nginx upstream:
```nginx
server {
server_name gnubok.example.com;
listen 443 ssl http2;
# ssl_certificate / ssl_certificate_key / etc.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Make sure `NEXT_PUBLIC_APP_URL` matches the public URL (e.g. `https://gnubok.example.com`).
---
## Troubleshooting
+28 -9
View File
@@ -1,5 +1,5 @@
# ── Stage 1: Base ──
FROM node:22-alpine AS base
FROM node:22-alpine@sha256:968df39aedcea65eeb078fb336ed7191baf48f972b4479711397108be0966920 AS base
RUN apk add --no-cache libc6-compat
# ── Stage 2: Dependencies ──
@@ -39,10 +39,12 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ── Stage 4: Runner ──
FROM node:22-alpine AS runner
FROM node:22-alpine@sha256:968df39aedcea65eeb078fb336ed7191baf48f972b4479711397108be0966920 AS runner
WORKDIR /app
RUN apk add --no-cache curl
# su-exec drops privileges in the entrypoint after the placeholder-substitution
# step. Healthcheck uses BusyBox wget (already present in alpine), so no curl.
RUN apk add --no-cache su-exec
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
@@ -50,15 +52,32 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# /app at runtime is split across the read-only image layer and tmpfs mounts:
# /app/server.js, /app/node_modules/, /app/package.json — image (read-only)
# /app/.next/ — tmpfs (writable)
# /app/public/ — tmpfs (writable)
# The entrypoint copies templates from /opt/gnubok-template/ into the tmpfs
# mounts at startup, runs placeholder substitution, then chmods read-only.
# This lets us run with docker-compose `read_only: true`.
COPY --from=builder /app/.next/standalone/server.js ./server.js
COPY --from=builder /app/.next/standalone/node_modules ./node_modules
COPY --from=builder /app/.next/standalone/package.json ./package.json
# Baked-in templates for runtime population of tmpfs mounts.
COPY --from=builder /app/.next/standalone/.next /opt/gnubok-template/.next
COPY --from=builder /app/.next/static /opt/gnubok-template/.next/static
COPY --from=builder /app/public /opt/gnubok-template/public
# Pre-create mount points so tmpfs has somewhere to attach when running with
# docker-compose's read_only:true. The directories are empty in the image
# layer — content is copied in by the entrypoint.
RUN mkdir -p /app/.next /app/.next/cache /app/public
# Copy entrypoint script
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
USER nextjs
# No USER directive — entrypoint handles the privilege drop with su-exec
# after the root-only setup steps complete.
EXPOSE 3000
ENV PORT=3000
+94 -18
View File
@@ -1,20 +1,81 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logger'
const log = createLogger('health')
const CACHE_TTL_MS = 5_000
type HealthBody = {
status: 'healthy' | 'unhealthy'
timestamp: string
version: string
}
type CheckResult = {
body: HealthBody
status: number
}
type CachedResult = CheckResult & { expires: number }
// In-memory cache shared across requests in the same process. Docker's
// healthcheck polls every 30 s, so the cache always returns fresh data to it,
// but a public flood (multiple requests/second) is served from RAM and never
// reaches Postgres. The cache is intentionally tiny — one entry — because the
// endpoint takes no parameters.
let cached: CachedResult | null = null
// Holds the pending check when one is in flight so concurrent cache misses
// share a single Postgres round-trip. Cleared as soon as the promise settles.
// Bounds the worst case to one DB query per CACHE_TTL_MS window regardless of
// burst arrival rate (e.g. a load-balancer replaying queued probes).
let pending: Promise<CheckResult> | null = null
/**
* GET /api/health
* Public health check endpoint (no auth required).
* Returns DB connectivity status for uptime monitoring.
*
* Error details are logged server-side only — never echoed to the response
* body, which would expose Postgres error text on a public endpoint. The
* logger receives only error.code/error.message; raw Supabase error objects
* may include schema names, table names, or query fragments that should
* never reach application logs.
*
* Results are cached for {@link CACHE_TTL_MS} so flood traffic does not
* hammer Postgres with a service-role query per request.
*/
export async function GET() {
const now = Date.now()
if (cached && cached.expires > now) {
return NextResponse.json(cached.body, { status: cached.status })
}
const inFlight = pending ?? (pending = runAndCache(now))
try {
const result = await inFlight
return NextResponse.json(result.body, { status: result.status })
} finally {
if (pending === inFlight) pending = null
}
}
async function runAndCache(now: number): Promise<CheckResult> {
const result = await runHealthCheck()
cached = { ...result, expires: now + CACHE_TTL_MS }
return result
}
async function runHealthCheck(): Promise<CheckResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: 'Missing configuration' },
{ status: 503 }
)
log.error('Missing Supabase configuration for health check')
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
try {
@@ -25,21 +86,36 @@ export async function GET() {
.limit(1)
if (error) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: error.message },
{ status: 503 }
)
// PostgrestError is a plain object: { code, message, details, hint }.
// details/hint can contain table or column names; log only the
// operationally useful fields.
log.error('Database health check failed', {
errCode: error.code ?? null,
errMessage: error.message ?? null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0',
})
return {
body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 200,
}
} catch (err) {
return NextResponse.json(
{ status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0', error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 503 }
)
// Caught Error instances are reduced to {name, message, code} by the
// logger's redactor; never pass the raw value lest a deep stack containing
// query strings ends up in production logs.
const e = err as { name?: unknown; message?: unknown; code?: unknown }
log.error('Health check unexpected error', {
errName: typeof e?.name === 'string' ? e.name : null,
errMessage: typeof e?.message === 'string' ? e.message : null,
errCode: typeof e?.code === 'string' ? e.code : null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
status: 503,
}
}
}
+363 -74
View File
@@ -200,13 +200,21 @@ export async function GET(request: Request) {
const scopeBindingValue = scopeParam ?? ''
const scopeBindingSignature = signScopeBinding(scopeBindingValue)
// The grant ceiling is the set of scopes the client requested, or
// DEFAULT_OAUTH_SCOPES when the client passed no scope param. Pre-checks
// everything in the ceiling. The POST handler enforces the same ceiling
// server-side so a tampered form can't widen the grant past what the
// client actually asked for (RFC 6749 §3.3, SOC 2 CC6.3).
// The consent UI is bounded to a server-enforced ceiling, regardless of
// what the user ticks:
//
// - Client requested specific scopes → ceiling = that set (RFC 6749 §3.3
// strict least-privilege).
// - Client passed no scope (or only the legacy `mcp` marker — the case
// Claude's connector hits today) → ceiling = DEFAULT_OAUTH_SCOPES
// (read-only). This preserves GDPR Art. 25(2) data-protection-by-default
// and keeps a server-enforced read-only guarantee for clients that
// never declared any intent. Widening the ceiling beyond
// DEFAULT_OAUTH_SCOPES requires the client to ask for it via the
// `scope` parameter.
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
const scopeCheckboxesHtml = renderScopeCheckboxes(grantCeiling, grantCeiling)
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
// Render consent page
const html = `<!DOCTYPE html>
@@ -215,83 +223,351 @@ export async function GET(request: Request) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="translate" content="no">
<meta name="color-scheme" content="light">
<title>Anslut MCP-klient — ${appNameLower}</title>
<style>
:root {
--bg: hsl(0 0% 100%);
--surface: hsl(0 0% 100%);
--secondary: hsl(40 11% 89%);
--secondary-hover: hsl(40 11% 84%);
--muted: hsl(40 8% 93%);
--border: hsl(45 5% 85%);
--border-strong: hsl(45 5% 72%);
--fg: hsl(0 0% 9%);
--fg-muted: hsl(0 0% 40%);
--fg-faint: hsl(0 0% 55%);
--primary: hsl(0 0% 9%);
--primary-hover: hsl(0 0% 20%);
--warning: hsl(38 55% 50%);
--warning-bg: hsl(38 60% 96%);
--warning-border: hsl(38 45% 82%);
--warning-fg: hsl(28 60% 28%);
--warm-accent: hsl(38 45% 52%);
--ring: hsl(0 0% 9%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: flex; align-items: flex-start; justify-content: center; min-height: 100vh; padding: 2rem 1rem; }
.card { background: white; border-radius: 12px; border: 1px solid #e5e5e5; padding: 2rem; max-width: 520px; width: 100%; }
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
p { font-size: 0.875rem; color: #666; line-height: 1.5; margin-bottom: 1rem; }
.account { font-size: 0.875rem; color: #111; font-weight: 500; background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
.scopes-header { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #666; margin-bottom: 0.75rem; }
.scopes-controls { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
.scopes-controls button { padding: 0.25rem 0.625rem; font-size: 0.75rem; font-weight: 500; background: white; color: #444; border: 1px solid #e5e5e5; border-radius: 6px; cursor: pointer; }
.scopes-controls button:hover { background: #f5f5f5; }
.scope-group { border: 1px solid #ececec; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; }
.scope-group-title { font-size: 0.8125rem; font-weight: 600; color: #111; margin-bottom: 0.5rem; }
.scope-row { display: flex; gap: 0.625rem; padding: 0.375rem 0; align-items: flex-start; }
.scope-row input { margin-top: 0.1875rem; cursor: pointer; }
.scope-row label { font-size: 0.8125rem; color: #333; cursor: pointer; line-height: 1.4; }
.scope-row .scope-name { font-weight: 500; color: #111; }
.scope-row .scope-desc { color: #666; font-size: 0.75rem; display: block; margin-top: 0.125rem; }
.scope-row.write .scope-name::after { content: " · skriv"; color: #b85c2c; font-weight: 500; }
.warn { font-size: 0.75rem; color: #8b5a00; background: #fff7e6; border: 1px solid #f0d6a1; border-radius: 6px; padding: 0.625rem 0.75rem; margin: 1rem 0; line-height: 1.4; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
.actions button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid #e5e5e5; }
.allow { background: #111; color: white; border-color: #111; }
.allow:hover { background: #333; }
.deny { background: white; color: #111; }
.deny:hover { background: #f5f5f5; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: 'Geist', -apple-system, system-ui, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 4rem 1.5rem 3rem;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
max-width: 560px;
width: 100%;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
margin-bottom: 0.875rem;
}
.eyebrow::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warm-accent);
}
h1 {
font-family: 'Hedvig Letters Serif', Georgia, 'Times New Roman', serif;
font-size: 2rem;
font-weight: 400;
letter-spacing: -0.018em;
line-height: 1.1;
color: var(--fg);
margin-bottom: 0.625rem;
}
.lede {
font-size: 0.875rem;
color: var(--fg-muted);
line-height: 1.55;
margin-bottom: 1.5rem;
}
.account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
margin-bottom: 1.75rem;
}
.account-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
}
.account-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--fg);
text-align: right;
word-break: break-word;
}
.scopes-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.625rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--border);
}
.scopes-title {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-muted);
}
.scopes-controls {
display: flex;
gap: 0.25rem;
}
.scopes-controls button {
padding: 0.3125rem 0.625rem;
font-family: inherit;
font-size: 0.6875rem;
font-weight: 500;
color: var(--fg-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: background 150ms, color 150ms, border-color 150ms;
}
.scopes-controls button:hover {
background: var(--secondary);
color: var(--fg);
border-color: var(--border-strong);
}
.scopes-controls button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.scope-group {
padding: 0.375rem 0;
border-bottom: 1px solid var(--border);
}
.scope-group:last-of-type { border-bottom: none; padding-bottom: 0; }
.scope-group-title {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
padding: 0.625rem 0 0.25rem;
}
.scope-row {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
margin: 0 -0.5rem;
align-items: flex-start;
border-radius: 6px;
transition: background 150ms;
}
.scope-row:hover { background: var(--secondary); }
.scope-row input[type="checkbox"] {
margin-top: 0.1875rem;
width: 15px;
height: 15px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
}
.scope-row input[type="checkbox"]:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
border-radius: 2px;
}
.scope-row label {
flex: 1;
cursor: pointer;
line-height: 1.45;
}
.scope-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.scope-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--fg);
}
.scope-desc {
display: block;
margin-top: 0.1875rem;
font-size: 0.75rem;
color: var(--fg-muted);
line-height: 1.5;
}
.scope-tag {
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.0625rem 0.375rem;
border-radius: 4px;
background: hsl(38 60% 92%);
color: hsl(28 65% 30%);
border: 1px solid hsl(38 45% 78%);
}
.warn {
display: flex;
gap: 0.625rem;
font-size: 0.75rem;
color: var(--warning-fg);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 0.75rem 0.875rem;
margin: 1.5rem 0 0;
line-height: 1.55;
}
.warn-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
margin-top: 0.125rem;
color: var(--warning);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1.75rem;
}
.actions button {
flex: 1;
padding: 0.6875rem 1rem;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1px solid;
transition: background 150ms, border-color 150ms, color 150ms;
}
.actions button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.allow {
background: var(--primary);
color: hsl(0 0% 100%);
border-color: var(--primary);
}
.allow:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
.deny {
background: var(--surface);
color: var(--fg);
border-color: var(--border-strong);
}
.deny:hover { background: var(--secondary); }
.footer {
margin-top: 1.25rem;
font-size: 0.6875rem;
color: var(--fg-faint);
text-align: center;
line-height: 1.5;
}
@media (max-width: 480px) {
body { padding: 1.5rem 1rem 2rem; }
.card { padding: 1.5rem; border-radius: 10px; }
h1 { font-size: 1.625rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; }
}
</style>
</head>
<body>
<div class="card">
<main class="card" role="main">
<div class="eyebrow">${appNameLower} · mcp</div>
<h1>Anslut MCP-klient</h1>
<p>En extern applikation vill ansluta till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill ge.</p>
<div class="account">${escapeHtml(companyName)}</div>
<p class="lede">En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill bevilja.</p>
<div class="account">
<span class="account-label">Företag</span>
<span class="account-name">${escapeHtml(companyName)}</span>
</div>
<form method="POST" action="${url.pathname}${url.search}" id="consent-form">
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
<div class="scopes-header">Behörigheter</div>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Markera alla</button>
<button type="button" id="select-none">Avmarkera alla</button>
<div class="scopes-header">
<span class="scopes-title">Behörigheter</span>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Alla</button>
<button type="button" id="select-none">Inga</button>
</div>
</div>
${scopeCheckboxesHtml}
<div class="warn">
Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Alla skrivoperationer kräver din godkännande i ${appNameLower} innan de skrivs till databasen.
<svg class="warn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 5v3.5" stroke-linecap="round"/>
<circle cx="8" cy="11" r="0.5" fill="currentColor" stroke="none"/>
</svg>
<span>Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Varje skrivoperation kräver ditt godkännande i ${appNameLower} innan den skrivs till databasen.</span>
</div>
<div class="actions">
<button type="submit" name="consent" value="deny" class="deny">Neka</button>
<button type="submit" name="consent" value="allow" class="allow">Tillåt</button>
<button type="submit" name="consent" value="allow" class="allow">Tillåt åtkomst</button>
</div>
</form>
<script nonce="${cspNonce}">
(function() {
var form = document.getElementById('consent-form');
var boxes = form.querySelectorAll('input[name="scopes"]');
function setAll(predicate) {
boxes.forEach(function(b) { b.checked = predicate(b); });
}
document.getElementById('select-read').addEventListener('click', function() {
setAll(function(b) { return b.dataset.kind === 'read'; });
});
document.getElementById('select-all').addEventListener('click', function() {
setAll(function() { return true; });
});
document.getElementById('select-none').addEventListener('click', function() {
setAll(function() { return false; });
});
})();
</script>
</div>
<p class="footer">Du kan när som helst återkalla åtkomsten under Inställningar &rsaquo; API-nycklar.</p>
</main>
<script nonce="${cspNonce}">
(function() {
var form = document.getElementById('consent-form');
var boxes = form.querySelectorAll('input[name="scopes"]');
function setAll(predicate) {
boxes.forEach(function(b) { b.checked = predicate(b); });
}
document.getElementById('select-read').addEventListener('click', function() {
setAll(function(b) { return b.dataset.kind === 'read'; });
});
document.getElementById('select-all').addEventListener('click', function() {
setAll(function() { return true; });
});
document.getElementById('select-none').addEventListener('click', function() {
setAll(function() { return false; });
});
})();
</script>
</body>
</html>`
@@ -394,16 +670,17 @@ export async function POST(request: Request) {
//
// 1. validateScopes drops any value that isn't in API_KEY_SCOPES — guards
// against forged values from a tampered POST.
// 2. The grant must be a subset of what the client *originally asked for*
// (the `scope` querystring on the GET). Otherwise a client that
// requested only read scopes could end up with write grants because
// the user ticked extra boxes — that's a least-privilege violation
// (RFC 6749 §3.3, SOC 2 CC6.3, NIST AC-6) and removes the client's
// ability to advertise the access surface it actually intends to use.
//
// When the client didn't pass a scope param at all (parsed.scopes is
// undefined), the consent UI defaults to DEFAULT_OAUTH_SCOPES — that becomes
// the implicit ceiling for the grant.
// 2. The grant must be a subset of the ceiling derived from the client's
// original request:
// • If the client requested specific scopes, the ceiling = that set
// (RFC 6749 §3.3 strict). A client that asked for only read scopes
// can never end up with write grants, even if the user tampered
// with the form (least-privilege, SOC 2 CC6.3, NIST AC-6).
// • If the client passed no scope (or only the `mcp` marker), the
// ceiling = DEFAULT_OAUTH_SCOPES (read-only). A client that never
// declared write intent cannot receive write grants, even if the
// user tampered with the form — preserving GDPR Art. 25(2)
// data-protection-by-default.
const submittedScopes = formData.getAll('scopes').filter((s): s is string => typeof s === 'string')
const validated = validateScopes(submittedScopes)
const clientCeiling: ApiKeyScope[] = parsed.scopes ?? [...DEFAULT_OAUTH_SCOPES]
@@ -434,10 +711,11 @@ export async function POST(request: Request) {
/**
* Render the scope checkbox UI grouped by domain. Only scopes in `ceiling`
* (the client's `scope` querystring, or DEFAULT_OAUTH_SCOPES) are surfaced —
* scopes outside the ceiling are dropped from the consent UI so the user
* can't tick boxes that the POST handler would refuse anyway. Pre-checks
* every visible row by default.
* are surfaced — scopes outside the ceiling are dropped from the consent UI
* so the user can't tick boxes that the POST handler would refuse anyway.
* The ceiling is either the client's `scope` querystring (when specified)
* or DEFAULT_OAUTH_SCOPES (when the client passed no scope), matching the
* server-side enforcement in the POST handler.
*/
function renderScopeCheckboxes(
preChecked: Set<ApiKeyScope>,
@@ -479,11 +757,22 @@ function renderScopeCheckboxes(
function scopeRow(scope: ApiKeyScope, checked: boolean, kind: 'read' | 'write'): string {
const meta = API_KEY_SCOPES[scope]
const id = `scope-${scope.replace(/[^a-z0-9]/gi, '-')}`
// Labels are formatted "Område — verb" (läs/skriv/hantera/godkänn). Pull the
// prefix as the display name and only render the verb as a tag for elevated
// scopes — read-only is the implicit default and doesn't need a tag.
const [namePart, verbPart] = meta.label.split(' — ')
const displayName = namePart ?? meta.label
const tagHtml = verbPart && kind === 'write'
? `<span class="scope-tag">${escapeHtml(verbPart)}</span>`
: ''
return `
<div class="scope-row ${kind}">
<input type="checkbox" id="${id}" name="scopes" value="${escapeHtml(scope)}" data-kind="${kind}" ${checked ? 'checked' : ''}>
<label for="${id}">
<span class="scope-name">${escapeHtml(meta.label)}</span>
<span class="scope-name-row">
<span class="scope-name">${escapeHtml(displayName)}</span>
${tagHtml}
</span>
<span class="scope-desc">${escapeHtml(meta.description)}</span>
</label>
</div>
+51
View File
@@ -0,0 +1,51 @@
# Optional TLS overlay. Adds a Caddy reverse proxy that auto-provisions
# Let's Encrypt certificates for ${DOMAIN}.
#
# Usage:
# 1. Set DOMAIN=app.example.com in .env (must resolve to this host's public IP)
# 2. Open ports 80 and 443 to the public internet (LE HTTP-01 challenge needs 80)
# 3. docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
#
# Caddy reaches the app over the internal Docker network; the app no longer
# binds a host port at all.
services:
app:
# Remove the loopback binding from the base file — traffic comes via Caddy.
ports: !reset null
caddy:
image: caddy:2-alpine@sha256:86deaf5e3d3408a6ccec08fbb79989783dd26e206ae10bcf78a801dc8c9ab794
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
- DOMAIN=${DOMAIN:?set DOMAIN in .env to enable TLS}
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Caddy needs NET_BIND_SERVICE to bind privileged ports 80/443.
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp
mem_limit: 256m
pids_limit: 50
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
caddy_data:
caddy_config:
+47 -4
View File
@@ -1,18 +1,47 @@
services:
app:
image: ghcr.io/erp-mafia/gnubok:latest
image: ghcr.io/erp-mafia/gnubok:${IMAGE_TAG:-latest}
env_file: .env
# Bound to loopback by default — put a TLS-terminating reverse proxy in
# front (see docker-compose.caddy.yml). Override PORT in .env to change
# the host port, or use the caddy overlay to remove the host binding.
ports:
- "${PORT:-3000}:3000"
- "127.0.0.1:${PORT:-3000}:3000"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
init: true
# tmpfs ordering matters — the parent /app/.next must be mounted before
# any sub-mounts. The entrypoint cp's image-baked templates into these
# mounts at startup, then sed-substitutes, then chmod -R a-w.
#
# mode=750: root-owned, group-readable. The entrypoint runs as root long
# enough to copy templates, substitute placeholders, and chown the cache
# directory to nextjs:nodejs (gid 1001). nodejs group members can read
# the partially-substituted bundle during startup; nothing else on the
# host can. After chmod a-w the served files are read-only for everyone.
tmpfs:
- /tmp
- /app/.next:uid=0,gid=1001,mode=750,size=400m
- /app/public:uid=0,gid=1001,mode=750,size=200m
mem_limit: 1g
cpus: 2
pids_limit: 200
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
start_period: 30s
start_interval: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
cron:
build:
@@ -27,4 +56,18 @@ services:
volumes:
- ./docker/crontab.self-hosted:/etc/supercronic/crontab:ro
init: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
mem_limit: 64m
pids_limit: 30
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
+38 -14
View File
@@ -27,27 +27,51 @@ if [ -n "$placeholders_found" ]; then
printf "WARNING: These variables appear to contain placeholder values:\n%bPlease set them to real values before running in production.\n" "$placeholders_found" >&2
fi
# Replace build-time placeholder sentinels with runtime env vars in static JS bundles.
# This allows a single pre-built image to work with any Supabase project.
if [ -d /app/.next/static ]; then
find /app/.next -type f \( -name '*.js' -o -name '*.html' -o -name '*.rsc' -o -name '*.meta' -o -name '*.body' \) -exec sed -i \
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \
-e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
{} +
# Populate writable mount points from the baked-in templates. Under
# docker-compose's read_only:true, /app/.next and /app/public are tmpfs
# mounts; cp populates them in RAM. Without read_only:true the directories
# were created empty in the Dockerfile, so cp still works.
if [ -d /opt/gnubok-template/.next ]; then
cp -R /opt/gnubok-template/.next/. /app/.next/
fi
if [ -d /opt/gnubok-template/public ]; then
cp -R /opt/gnubok-template/public/. /app/public/
fi
# Ensure Next.js's runtime cache directory is writable by the unprivileged user.
mkdir -p /app/.next/cache
chown -R nextjs:nodejs /app/.next/cache
chmod 755 /app/.next/cache
# Substitute build-time placeholder sentinels with runtime env values.
find /app/.next -type f \( -name '*.js' -o -name '*.html' -o -name '*.rsc' -o -name '*.meta' -o -name '*.body' \) -exec sed -i \
-e "s|__NEXT_PUBLIC_SUPABASE_URL__|${NEXT_PUBLIC_SUPABASE_URL}|g" \
-e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \
-e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \
-e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \
-e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \
-e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
{} +
# Stamp the service worker fallback notification title with the brand name.
# public/sw.js is served as a static file (not bundled by Next), so NEXT_PUBLIC_*
# inlining doesn't reach it — substitute the placeholder here at container start.
# inlining doesn't reach it.
if [ -f /app/public/sw.js ]; then
sed -i \
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
/app/public/sw.js
fi
exec "$@"
# Make the served JS bundle immutable. A runtime RCE in the Node process
# cannot rewrite what other users will receive. Cache stays writable so
# Next.js can populate its image-optimization / ISR caches.
chmod -R a-w /app/.next/static
[ -d /app/.next/server ] && chmod -R a-w /app/.next/server
find /app/.next -maxdepth 1 -type f -exec chmod a-w {} +
[ -f /app/public/sw.js ] && chmod a-w /app/public/sw.js
# Drop privileges. The Node server runs as the unprivileged nextjs:nodejs user;
# the now-immutable static dir is root-owned, so even a process compromise
# inside Node cannot rewrite the served JS bundle.
exec su-exec nextjs:nodejs "$@"
+11
View File
@@ -0,0 +1,11 @@
{$DOMAIN} {
reverse_proxy app:3000
# HSTS: lock clients onto HTTPS for one year.
# `preload` is intentionally omitted — submission to browser preload lists
# is irreversible on short timescales (months of lead time to remove a
# domain). Operators who want preload eligibility can add the directive
# after committing to HTTPS-only permanently and submitting via
# hstspreload.org.
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
+22 -3
View File
@@ -1,13 +1,32 @@
FROM alpine:3.19
FROM alpine:3.22@sha256:310c62b5e7ca5b08167e4384c68db0fd2905dd9c7493756d356e893909057601
ARG SUPERCRONIC_VERSION=v0.2.33
ARG TARGETARCH
# SHA-256 of the supercronic v0.2.33 release binaries.
# Computed from https://github.com/aptible/supercronic/releases/download/v0.2.33/
# (the upstream project publishes only SHA-1 checksums, so these are recorded here).
# Dependabot watches FROM lines, not these ARGs — bump manually when SUPERCRONIC_VERSION changes.
ARG SUPERCRONIC_SHA256_AMD64=feefa310da569c81b99e1027b86b27b51e6ee9ab647747b49099645120cfc671
ARG SUPERCRONIC_SHA256_ARM64=f1f8585c66de020fef494dd636058f99949d108f569fef00016a1c8b9eb145b3
# curl stays in the image — the crontab uses it at runtime to call the app.
RUN apk add --no-cache curl \
&& ARCH=$(case ${TARGETARCH} in amd64) echo "linux-amd64";; arm64) echo "linux-arm64";; *) echo "linux-amd64";; esac) \
&& case ${TARGETARCH} in \
amd64) ARCH=linux-amd64; SHA=${SUPERCRONIC_SHA256_AMD64} ;; \
arm64) ARCH=linux-arm64; SHA=${SUPERCRONIC_SHA256_ARM64} ;; \
*) ARCH=linux-amd64; SHA=${SUPERCRONIC_SHA256_AMD64} ;; \
esac \
&& curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-${ARCH}" \
-o /usr/local/bin/supercronic \
&& chmod +x /usr/local/bin/supercronic
&& echo "${SHA} /usr/local/bin/supercronic" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/supercronic
# Run as the alpine-built-in unprivileged user. Defense-in-depth alongside
# cap_drop:[ALL] and read_only:true in docker-compose.yml. The crontab is
# bind-mounted read-only with default 644 perms (readable by all), and the
# supercronic binary is world-executable (chmod 0755 above).
USER nobody:nobody
ENTRYPOINT ["supercronic"]
CMD ["/etc/supercronic/crontab"]
@@ -0,0 +1,126 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser } from '@/tests/pg/fixtures'
// Authorization tests for the team_id branch of create_company_with_owner
// added in 20260519180000_enforce_team_membership_in_create_company.sql.
// The RPC is SECURITY DEFINER and bypasses RLS on the companies INSERT, so
// without the in-body membership check any authenticated user could attach a
// freshly-created company to an arbitrary team_id (OWASP ASVS V8.2.1).
//
// These tests prove the check fires for non-members and passes for members
// (both owners and ordinary members), while preserving the NULL-team_id path
// for solo companies.
async function insertTeam(params: { createdBy: string; name?: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.teams (id, name, created_by)
VALUES ($1, $2, $3)`,
[id, params.name ?? 'Test Team', params.createdBy],
)
// Owner is stored as a team_members row with role='owner' (per
// 20260331010000_teams_table_refactor.sql section 3c).
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role)
VALUES ($1, $2, 'owner')`,
[id, params.createdBy],
)
return id
}
async function insertTeamMember(params: {
teamId: string
userId: string
role?: 'admin' | 'member'
}): Promise<void> {
await getPool().query(
`INSERT INTO public.team_members (team_id, user_id, role)
VALUES ($1, $2, $3)`,
[params.teamId, params.userId, params.role ?? 'member'],
)
}
describe('create_company_with_owner — team_id authorization', () => {
it('raises when caller is not a member of the requested team', async () => {
const ownerId = await insertAuthUser()
const intruderId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
await expect(
withUserContext(intruderId, async (client) => {
await client.query(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Intruder AB', 'aktiebolag', false, teamId],
)
}),
).rejects.toThrow(/Not a member of team/)
})
it('succeeds when caller is the team owner', async () => {
const ownerId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
const companyId = await withUserContext(ownerId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Owner AB', 'aktiebolag', false, teamId],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('succeeds when caller is a team member (not owner)', async () => {
const ownerId = await insertAuthUser()
const memberId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
await insertTeamMember({ teamId, userId: memberId, role: 'member' })
const companyId = await withUserContext(memberId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Member AB', 'aktiebolag', false, teamId],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('skips the membership check when p_team_id is NULL (solo company path)', async () => {
const soloId = await insertAuthUser()
const companyId = await withUserContext(soloId, async (client) => {
const { rows } = await client.query<{ create_company_with_owner: string }>(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Solo EF', 'enskild_firma', false, null],
)
return rows[0]!.create_company_with_owner
})
expect(companyId).toMatch(/^[0-9a-f-]{36}$/)
})
it('uses insufficient_privilege (SQLSTATE 42501) for the team-membership rejection', async () => {
const ownerId = await insertAuthUser()
const intruderId = await insertAuthUser()
const teamId = await insertTeam({ createdBy: ownerId })
let sqlstate: string | undefined
try {
await withUserContext(intruderId, async (client) => {
await client.query(
`SELECT public.create_company_with_owner($1, $2, $3, $4)`,
['Intruder AB', 'aktiebolag', false, teamId],
)
})
} catch (err) {
sqlstate = (err as { code?: string }).code
}
expect(sqlstate).toBe('42501')
})
})
@@ -0,0 +1,82 @@
-- Migration: fix create_company_with_owner overload ambiguity
--
-- Background: 20260331010000_teams_table_refactor.sql defined the canonical
-- 4-arg signature:
-- create_company_with_owner(p_name text, p_entity_type text,
-- p_set_active boolean DEFAULT true,
-- p_team_id uuid DEFAULT NULL)
--
-- 20260519154732_seed_default_cash_account.sql then issued a CREATE OR REPLACE
-- with a 3-arg signature (dropping p_team_id) to add cash_accounts seeding.
-- CREATE OR REPLACE only matches when the parameter list is identical, so the
-- 3-arg form was created as a *new* overload rather than replacing the 4-arg
-- one. Both functions now coexist in production and both can be called with
-- two args (the third has a default), so PostgREST cannot resolve the call
-- and returns 300 Multiple Choices for any 2-arg invocation — breaking
-- /api/sandbox/seed (POST /rpc/create_company_with_owner failed with 300).
--
-- Fix: drop the 3-arg orphan and re-create the canonical 4-arg version with
-- the cash_accounts seeding merged in.
DROP FUNCTION IF EXISTS public.create_company_with_owner(text, text, boolean);
CREATE OR REPLACE FUNCTION public.create_company_with_owner(
p_name text,
p_entity_type text,
p_set_active boolean DEFAULT true,
p_team_id uuid DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
END IF;
INSERT INTO public.companies (name, entity_type, created_by, team_id)
VALUES (p_name, p_entity_type, v_user_id, p_team_id)
RETURNING id INTO v_company_id;
INSERT INTO public.company_members (company_id, user_id, role)
VALUES (v_company_id, v_user_id, 'owner');
-- Seed default 1930 SEK cash account so reconciliation routes work before
-- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__
-- sentinel in skattekonto-booking resolves on day one.
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
)
VALUES (
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
)
ON CONFLICT (company_id, ledger_account) DO NOTHING;
IF p_set_active THEN
INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES (v_user_id, v_company_id)
ON CONFLICT (user_id)
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
END IF;
IF p_team_id IS NOT NULL THEN
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
END IF;
RETURN v_company_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,94 @@
-- Migration: enforce team membership in create_company_with_owner
--
-- 20260519170000_fix_create_company_with_owner_overload.sql re-introduced the
-- 4-arg RPC but the team-association path performs no authorization check on
-- p_team_id. A SECURITY DEFINER function that accepts a team_id from an
-- authenticated user must verify the caller is a member of that team — RLS
-- on companies.team_id would normally guard this, but the INSERT runs as the
-- definer role and bypasses RLS.
--
-- Without this check, any authenticated user can attach a freshly-created
-- company to a team they do not belong to. Team-member sync would then leak
-- a team's other consultants into a company they had no relationship with
-- (OWASP ASVS V8.2.1).
--
-- Fix: assert (auth.uid()) has a team_members row for p_team_id before
-- inserting. Team owners are stored as team_members rows with role='owner'
-- (see 20260331010000_teams_table_refactor.sql section 3c), so the single
-- team_members lookup covers both owners and members.
CREATE OR REPLACE FUNCTION public.create_company_with_owner(
p_name text,
p_entity_type text,
p_set_active boolean DEFAULT true,
p_team_id uuid DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
END IF;
-- Authorize p_team_id before any write. SECURITY DEFINER bypasses RLS, so
-- we must verify membership ourselves; without this any authenticated user
-- could attach a company to an arbitrary team.
IF p_team_id IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1
FROM public.team_members
WHERE team_id = p_team_id
AND user_id = v_user_id
) THEN
RAISE EXCEPTION 'Not a member of team %', p_team_id
USING ERRCODE = '42501'; -- insufficient_privilege
END IF;
END IF;
INSERT INTO public.companies (name, entity_type, created_by, team_id)
VALUES (p_name, p_entity_type, v_user_id, p_team_id)
RETURNING id INTO v_company_id;
INSERT INTO public.company_members (company_id, user_id, role)
VALUES (v_company_id, v_user_id, 'owner');
-- Seed default 1930 SEK cash account so reconciliation routes work before
-- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__
-- sentinel in skattekonto-booking resolves on day one.
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
)
VALUES (
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
)
ON CONFLICT (company_id, ledger_account) DO NOTHING;
IF p_set_active THEN
INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES (v_user_id, v_company_id)
ON CONFLICT (user_id)
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
END IF;
IF p_team_id IS NOT NULL THEN
PERFORM public.sync_team_to_company(v_company_id, p_team_id);
END IF;
RETURN v_company_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';