feat: Infrastructure multi-tenant avec isolation par sous-domaine
Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
This commit is contained in:
@@ -9,6 +9,10 @@ FROM node:22-alpine AS base
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
# Configure pnpm to use a directory inside the project (works with volume mounts)
|
||||
ENV PNPM_HOME=/app/.pnpm-store
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -17,15 +21,57 @@ WORKDIR /app
|
||||
# =============================================================================
|
||||
FROM base AS dev
|
||||
|
||||
# Create entrypoint script for dev (installs deps if needed)
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'if [ ! -d /app/node_modules ] || [ ! -f /app/node_modules/.pnpm/lock.yaml ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' echo "Installing pnpm dependencies..."' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo ' pnpm install' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
echo 'exec "$@"' >> /usr/local/bin/docker-entrypoint.sh && \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Install gosu for proper user switching
|
||||
ENV GOSU_VERSION=1.17
|
||||
RUN set -eux; \
|
||||
apk add --no-cache --virtual .gosu-deps dpkg gnupg; \
|
||||
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
|
||||
chmod +x /usr/local/bin/gosu; \
|
||||
gosu --version; \
|
||||
gosu nobody true; \
|
||||
apk del --no-network .gosu-deps
|
||||
|
||||
# Entrypoint: detect host UID/GID and run as matching user
|
||||
# Uses gosu with UID:GID directly (no need to create user in Dockerfile)
|
||||
COPY --chmod=755 <<'EOF' /usr/local/bin/docker-entrypoint.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Detect UID/GID from mounted /app directory
|
||||
HOST_UID=$(stat -c %u /app)
|
||||
HOST_GID=$(stat -c %g /app)
|
||||
|
||||
# If root owns /app, run as root (CI environment or volume not mounted)
|
||||
if [ "$HOST_UID" = "0" ]; then
|
||||
# Install dependencies if not present
|
||||
if [ ! -d /app/node_modules ] || [ ! -f /app/node_modules/.pnpm/lock.yaml ]; then
|
||||
echo "Installing pnpm dependencies..."
|
||||
pnpm install
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Fix node_modules volume ownership (Docker creates volumes as root)
|
||||
# This only takes time on first run when the volume is empty
|
||||
if [ -d /app/node_modules ] && [ "$(stat -c %u /app/node_modules)" = "0" ]; then
|
||||
echo "Fixing node_modules ownership..."
|
||||
chown -R "$HOST_UID:$HOST_GID" /app/node_modules
|
||||
fi
|
||||
|
||||
# Ensure pnpm store directory exists and is writable
|
||||
mkdir -p /app/.pnpm-store
|
||||
chown "$HOST_UID:$HOST_GID" /app/.pnpm-store
|
||||
|
||||
# Install pnpm dependencies if not present (as host user)
|
||||
if [ ! -d /app/node_modules/.pnpm ]; then
|
||||
echo "Installing pnpm dependencies..."
|
||||
gosu "$HOST_UID:$HOST_GID" pnpm install
|
||||
fi
|
||||
|
||||
# Run command as host user via gosu (using UID:GID directly)
|
||||
exec gosu "$HOST_UID:$HOST_GID" "$@"
|
||||
EOF
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
|
||||
62
frontend/src/lib/api/config.ts
Normal file
62
frontend/src/lib/api/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
/**
|
||||
* Construit l'URL de base de l'API en fonction du hostname actuel.
|
||||
*
|
||||
* En multi-tenant, l'API utilise le même sous-domaine que le frontend
|
||||
* pour garantir l'isolation des données par établissement.
|
||||
*
|
||||
* Exemples :
|
||||
* - Dev: ecole-alpha.classeo.local:5174 -> ecole-alpha.classeo.local:18000/api
|
||||
* - Prod: ecole-alpha.classeo.fr -> ecole-alpha.classeo.fr/api (relative)
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
// Côté browser : toujours utiliser le hostname actuel pour préserver le tenant
|
||||
if (browser) {
|
||||
const { hostname, protocol } = window.location;
|
||||
|
||||
// En prod (pas de port API séparé), utiliser une URL relative
|
||||
// Cela préserve automatiquement le sous-domaine du tenant
|
||||
if (!env['PUBLIC_API_PORT']) {
|
||||
return '/api';
|
||||
}
|
||||
|
||||
// En dev, construire l'URL avec le hostname actuel et le port API
|
||||
const apiPort = env['PUBLIC_API_PORT'];
|
||||
return `${protocol}//${hostname}:${apiPort}/api`;
|
||||
}
|
||||
|
||||
// SSR : utiliser PUBLIC_API_URL si défini, sinon fallback interne
|
||||
if (env['PUBLIC_API_URL']) {
|
||||
return env['PUBLIC_API_URL'];
|
||||
}
|
||||
|
||||
// SSR fallback : communication interne Docker
|
||||
return 'http://php:8000/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le sous-domaine (tenant) du hostname actuel.
|
||||
*
|
||||
* Exemple : ecole-alpha.classeo.local -> ecole-alpha
|
||||
*/
|
||||
export function getCurrentTenant(): string | null {
|
||||
if (!browser) return null;
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const baseDomain = env['PUBLIC_BASE_DOMAIN'] || 'classeo.local';
|
||||
|
||||
if (!hostname.endsWith(baseDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subdomain = hostname.replace(`.${baseDomain}`, '');
|
||||
|
||||
// Pas de sous-domaine ou sous-domaine réservé
|
||||
if (!subdomain || subdomain === hostname || subdomain === 'www') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subdomain;
|
||||
}
|
||||
1
frontend/src/lib/api/index.ts
Normal file
1
frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getApiBaseUrl, getCurrentTenant } from './config';
|
||||
@@ -69,6 +69,8 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true
|
||||
strictPort: true,
|
||||
// Autorise les sous-domaines pour le multi-tenant (dev + prod)
|
||||
allowedHosts: ['.classeo.local', '.classeo.fr', 'localhost']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user