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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 › 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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
@@ -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
@@ -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';
|
||||
Reference in New Issue
Block a user