feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run

This commit is contained in:
CodexPi
2026-11-14 20:23:59 +01:00
commit 995b17a6e2
295 changed files with 36803 additions and 0 deletions

1
django_ui/__init__.py Normal file
View File

@@ -0,0 +1 @@

6
django_ui/asgi.py Normal file
View File

@@ -0,0 +1,6 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_ui.settings")
application = get_asgi_application()

View File

@@ -0,0 +1 @@
"""Application permissions attached to the existing Django identity."""

View File

@@ -0,0 +1,37 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
APPLICATIONS = (
"auto-care", "autonomous-dev-studio", "codex-commander", "knowledge-graph",
"loutre-brain", "loutre-finance", "loutre-home", "loutre-os",
"nasflix-ai-media-curator", "nasflix-media-health", "network-guardian",
"pi-doctor", "print-pilot", "pwa-factory", "reverse-proxy-manager",
"smart-inventory", "trip-organizer",
)
ROLES = ("reader", "operator", "admin")
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="ApplicationGrant",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("application", models.CharField(max_length=64, choices=[(name, name) for name in APPLICATIONS])),
("role", models.CharField(max_length=16, choices=[(name, name) for name in ROLES])),
("updated_at", models.DateTimeField(auto_now=True)),
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
"constraints": [
models.UniqueConstraint(fields=("user", "application"), name="identity_user_application"),
models.CheckConstraint(condition=models.Q(application__in=APPLICATIONS), name="identity_known_application"),
models.CheckConstraint(condition=models.Q(role__in=ROLES), name="identity_known_role"),
]
},
)
]

View File

@@ -0,0 +1,25 @@
from django.conf import settings
from django.db import models
APPLICATIONS = (
"auto-care", "autonomous-dev-studio", "codex-commander", "knowledge-graph",
"loutre-brain", "loutre-finance", "loutre-home", "loutre-os",
"nasflix-ai-media-curator", "nasflix-media-health", "network-guardian",
"pi-doctor", "print-pilot", "pwa-factory", "reverse-proxy-manager",
"smart-inventory", "trip-organizer",
)
ROLES = ("reader", "operator", "admin")
class ApplicationGrant(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
application = models.CharField(max_length=64, choices=[(name, name) for name in APPLICATIONS])
role = models.CharField(max_length=16, choices=[(name, name) for name in ROLES])
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=("user", "application"), name="identity_user_application"),
models.CheckConstraint(condition=models.Q(application__in=APPLICATIONS), name="identity_known_application"),
models.CheckConstraint(condition=models.Q(role__in=ROLES), name="identity_known_role"),
]

104
django_ui/identity/views.py Normal file
View File

@@ -0,0 +1,104 @@
"""Same-origin identity contract in the existing Django service, deny by default."""
from functools import wraps
from django.contrib.auth import get_user_model
from django.db import DatabaseError
from django.http import JsonResponse
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from django_ui.views import AUDIT, client_ip, json_body, security_headers
from .models import APPLICATIONS, ROLES, ApplicationGrant
PERMISSIONS = {"reader": ("read",), "operator": ("read", "write"), "admin": ("read", "write", "admin")}
def reply(body, status=200):
response = security_headers(JsonResponse(body, status=status))
response["Vary"] = "Cookie"
return response
def identity_request(view):
@wraps(view)
def guarded(request, application, **kwargs):
try:
if not request.user.is_authenticated:
return reply({"detail": "Authentification requise"}, 401)
if application not in APPLICATIONS:
return reply({"detail": "Application inconnue"}, 404)
if request.headers.get("Sec-Fetch-Site") == "cross-site":
return reply({"detail": "Requête intersite refusée"}, 403)
if request.method not in {"GET", "HEAD", "OPTIONS"} and (
request.content_type != "application/json" or len(request.body) > 4096
):
return reply({"detail": "Corps JSON limité requis"}, 400)
return view(request, application, **kwargs)
except DatabaseError:
return reply({"detail": "Identité indisponible ; accès refusé"}, 503)
return guarded
def principal(request, application):
grant = ApplicationGrant.objects.filter(user=request.user, application=application).first()
if not grant or grant.role not in PERMISSIONS:
return None
return {
"issuer": "django-codexpi",
"subject": str(request.user.pk),
"username": request.user.get_username(),
"application": application,
"role": grant.role,
"permissions": list(PERMISSIONS[grant.role]),
}
@require_GET
@identity_request
def application_identity(request, application):
identity = principal(request, application)
return reply(identity) if identity else reply({"detail": "Accès à cette application refusé"}, 403)
@require_POST
@identity_request
def authorize(request, application):
body = json_body(request)
if set(body) != {"permission"} or body["permission"] not in ("read", "write", "admin"):
return reply({"detail": "Permission invalide"}, 400)
identity = principal(request, application)
if not identity or body["permission"] not in identity["permissions"]:
return reply({"detail": "Permission refusée"}, 403)
return reply({**identity, "authorized_permission": body["permission"]})
@require_http_methods(["GET", "PUT", "DELETE"])
@identity_request
def application_grant(request, application, user_id):
if not (request.user.is_staff or request.user.is_superuser):
return reply({"detail": "Administration des identités requise"}, 403)
target = get_user_model().objects.filter(pk=user_id).first()
if not target:
return reply({"detail": "Compte introuvable"}, 404)
grants = ApplicationGrant.objects.filter(user=target, application=application)
if request.method == "GET":
grant = grants.first()
return reply({"subject": str(target.pk), "application": application, "role": grant.role if grant else None})
if request.method == "DELETE":
grants.delete()
role = None
else:
body = json_body(request)
if set(body) != {"role"} or body["role"] not in ROLES:
return reply({"detail": "Rôle invalide"}, 400)
role = body["role"]
ApplicationGrant.objects.update_or_create(user=target, application=application, defaults={"role": role})
AUDIT.log(
"identity.grant_revoked" if role is None else "identity.grant_updated",
client_ip(request),
username=request.user.get_username(),
project_id=application,
outcome="allowed",
reason=f"target_user={target.pk};role={role or 'none'}",
)
return reply({"subject": str(target.pk), "application": application, "role": role})

98
django_ui/settings.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import os
import secrets
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = Path(os.getenv("CODEXPI_DATA_DIR", BASE_DIR / "data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
def env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
return default if value is None else value.lower() in {"1", "true", "yes", "on"}
def secret_key() -> str:
configured_file = os.getenv("CODEXPI_DJANGO_SECRET_KEY_FILE")
if configured_file:
value = Path(configured_file).read_text(encoding="utf-8").strip()
if len(value) < 32:
raise RuntimeError("CODEXPI_DJANGO_SECRET_KEY_FILE doit contenir un secret d’au moins 32 caractères")
return value
configured = os.getenv("CODEXPI_DJANGO_SECRET_KEY")
if configured:
if len(configured) < 32:
raise RuntimeError("CODEXPI_DJANGO_SECRET_KEY doit contenir au moins 32 caractères")
return configured
path = DATA_DIR / "django_secret_key"
if not path.exists():
try:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as target:
target.write(secrets.token_urlsafe(64))
except FileExistsError:
pass
return path.read_text(encoding="utf-8").strip()
SECRET_KEY = secret_key()
DEBUG = False
ALLOWED_HOSTS = [item.strip() for item in os.getenv("CODEXPI_DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,.ts.net,nginx").split(",") if item.strip()]
CSRF_TRUSTED_ORIGINS = [item.strip() for item in os.getenv("CODEXPI_TRUSTED_ORIGINS", "https://*.ts.net").split(",") if item.strip()]
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django_ui.identity",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "django_ui.urls"
ASGI_APPLICATION = "django_ui.asgi.application"
WSGI_APPLICATION = "django_ui.wsgi.application"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": DATA_DIR / "django.sqlite3", "OPTIONS": {"timeout": 30}}}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
USE_TZ = True
TIME_ZONE = "Europe/Paris"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 12}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_COOKIE_NAME = os.getenv("CODEXPI_COOKIE_NAME", "codexpi_session")
SESSION_COOKIE_AGE = int(os.getenv("CODEXPI_SESSION_HOURS", "12")) * 3600
SESSION_COOKIE_SECURE = env_bool("CODEXPI_COOKIE_SECURE", True)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Strict"
SESSION_SAVE_EVERY_REQUEST = False
CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Strict"
CSRF_HEADER_NAME = "HTTP_X_CSRF_TOKEN"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "no-referrer"
SECURE_HSTS_SECONDS = 31_536_000
X_FRAME_OPTIONS = "DENY"
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "codexpi-login-limiter"}}

21
django_ui/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path, re_path
from . import views
from .identity import views as identity
urlpatterns = [
path("api/bootstrap", views.bootstrap, name="bootstrap"),
path("api/auth/login", views.login_api, name="login"),
path("api/auth/setup", views.setup_api, name="setup"),
path("api/auth/logout", views.logout_api, name="logout"),
path("api/auth/me", views.me_api, name="me"),
path("api/auth/applications/<slug:application>", identity.application_identity, name="application-identity"),
path("api/auth/applications/<slug:application>/authorize", identity.authorize, name="application-authorize"),
path("api/auth/application-grants/<slug:application>/<int:user_id>", identity.application_grant, name="application-grant"),
path("manifest.webmanifest", views.manifest, name="manifest"),
path("version.json", views.frontend_version, name="frontend-version"),
path("sw.js", views.service_worker, name="service-worker"),
re_path(r"^assets/(?P<path>.+)$", views.asset, name="asset"),
re_path(r"^icons/(?P<path>.+)$", views.icon, name="icon"),
re_path(r"^.*$", views.index, name="index"),
]

192
django_ui/views.py Normal file
View File

@@ -0,0 +1,192 @@
from __future__ import annotations
import json
import hashlib
import time
from collections import deque
from pathlib import Path
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model, login, logout
from django.core.cache import cache
from django.http import FileResponse, Http404, JsonResponse
from django.middleware.csrf import get_token, rotate_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_GET, require_POST
from codexpi.security_audit import SecurityAuditLogger
FRONTEND = settings.BASE_DIR / "frontend" / "dist"
AUDIT = SecurityAuditLogger(settings.DATA_DIR / "security.log")
WINDOW_SECONDS = 300
MAX_ATTEMPTS = 8
def client_ip(request) -> str:
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
return (forwarded.split(",", 1)[0].strip() if forwarded else "") or request.META.get("HTTP_X_REAL_IP") or request.META.get("REMOTE_ADDR") or "unknown"
def audit_session_id(session_key: str | None) -> str | None:
"""Return a non-reversible correlation id, never the session cookie value."""
if not session_key:
return None
return f"sha256:{hashlib.sha256(session_key.encode()).hexdigest()[:20]}"
def security_headers(response):
response["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response["Cache-Control"] = "no-store"
return response
def json_body(request) -> dict:
try:
value = json.loads(request.body or b"{}")
except (json.JSONDecodeError, UnicodeDecodeError):
value = {}
return value if isinstance(value, dict) else {}
def rate_state(key: str) -> deque[float]:
now = time.monotonic()
values = deque(cache.get(key, []))
while values and values[0] < now - WINDOW_SECONDS:
values.popleft()
return values
@require_GET
@ensure_csrf_cookie
def bootstrap(request):
token = get_token(request)
return security_headers(JsonResponse({"setup_required": not get_user_model().objects.exists(), "cookie_secure": settings.SESSION_COOKIE_SECURE, "csrf_token": token}))
@require_POST
def login_api(request):
source = client_ip(request)
body = json_body(request)
username = str(body.get("username", ""))[:150]
password = str(body.get("password", ""))[:512]
ip_key = f"login-ip:{source}"
user_key = f"login-user:{username.lower()}"
events = rate_state(ip_key)
if len(events) >= MAX_ATTEMPTS or cache.get(f"locked:{username.lower()}"):
AUDIT.log("auth.login_rate_limited", source, username=username, outcome="denied", reason="rate_limit_or_lock")
return security_headers(JsonResponse({"detail": "Trop de tentatives. Réessayez plus tard."}, status=429))
user = authenticate(request, username=username, password=password)
if user is None or not user.is_active:
now = time.monotonic()
events.append(now)
cache.set(ip_key, list(events), WINDOW_SECONDS)
failures = int(cache.get(user_key, 0)) + 1
cache.set(user_key, failures, 900)
if failures >= 5:
cache.set(f"locked:{username.lower()}", True, 900)
time.sleep(min(0.25 * (2 ** max(0, failures - 1)), 4.0))
AUDIT.log("auth.login_failed", source, username=username, outcome="denied", reason="invalid_or_locked")
return security_headers(JsonResponse({"detail": "Identifiants invalides ou compte temporairement verrouillé"}, status=401))
cache.delete(ip_key)
cache.delete(user_key)
cache.delete(f"locked:{username.lower()}")
login(request, user)
rotate_token(request)
csrf_token = get_token(request)
request.session["codexpi_username"] = user.get_username()
request.session["codexpi_is_admin"] = bool(user.is_staff or user.is_superuser)
request.session["codexpi_api_csrf"] = csrf_token
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
request.session.save()
correlation_id = audit_session_id(request.session.session_key)
AUDIT.log("auth.login_succeeded", source, username=user.get_username(), session_id=correlation_id, outcome="allowed")
AUDIT.log("session.created", source, username=user.get_username(), session_id=correlation_id, outcome="created")
return security_headers(JsonResponse({"username": user.get_username(), "is_admin": bool(user.is_staff or user.is_superuser), "csrf_token": csrf_token, "expires_at": request.session.get_expiry_date().isoformat()}))
@require_POST
def setup_api(request):
User = get_user_model()
if User.objects.exists():
return security_headers(JsonResponse({"detail": "La configuration initiale est déjà terminée"}, status=409))
body = json_body(request)
username = str(body.get("username", "")).strip()
password = str(body.get("password", ""))
if not username or len(username) < 3 or len(password) < 12:
return security_headers(JsonResponse({"detail": "Utilisez un nom valide et un mot de passe d’au moins 12 caractères"}, status=400))
user = User.objects.create_superuser(username=username, password=password)
login(request, user)
rotate_token(request)
csrf_token = get_token(request)
request.session["codexpi_username"] = user.get_username()
request.session["codexpi_is_admin"] = True
request.session["codexpi_api_csrf"] = csrf_token
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
request.session.save()
AUDIT.log("auth.initial_admin_created", client_ip(request), username=user.get_username(), outcome="created")
return security_headers(JsonResponse({"username": user.get_username(), "is_admin": True, "csrf_token": csrf_token}, status=201))
@require_POST
def logout_api(request):
if not request.user.is_authenticated:
return security_headers(JsonResponse({"detail": "Authentification requise"}, status=401))
source = client_ip(request)
username = request.user.get_username()
session_id = audit_session_id(request.session.session_key)
logout(request)
AUDIT.log("session.deleted", source, username=username, session_id=session_id, outcome="revoked")
return security_headers(JsonResponse({"ok": True}))
@require_GET
def me_api(request):
if not request.user.is_authenticated:
return security_headers(JsonResponse({"detail": "Authentification requise"}, status=401))
token = request.session.get("codexpi_api_csrf")
if not token:
token = get_token(request)
request.session["codexpi_api_csrf"] = token
return security_headers(JsonResponse({"username": request.user.get_username(), "is_admin": bool(request.user.is_staff or request.user.is_superuser), "csrf_token": token, "expires_at": request.session.get_expiry_date().isoformat()}))
def safe_file(root: Path, relative: str) -> Path:
candidate = (root / relative).resolve()
resolved = root.resolve()
if candidate != resolved and resolved not in candidate.parents:
raise Http404
if not candidate.is_file():
raise Http404
return candidate
def index(_request):
response = FileResponse(safe_file(FRONTEND, "index.html").open("rb"), content_type="text/html")
return security_headers(response)
def asset(_request, path: str):
return FileResponse(safe_file(FRONTEND / "assets", path).open("rb"))
def icon(_request, path: str):
return FileResponse(safe_file(FRONTEND / "icons", path).open("rb"))
def manifest(_request):
return FileResponse(safe_file(FRONTEND, "manifest.webmanifest").open("rb"), content_type="application/manifest+json")
def frontend_version(_request):
response = FileResponse(safe_file(FRONTEND, "version.json").open("rb"), content_type="application/json")
response["Cache-Control"] = "no-store"
return response
def service_worker(_request):
response = FileResponse(safe_file(FRONTEND, "sw.js").open("rb"), content_type="application/javascript")
response["Service-Worker-Allowed"] = "/"
response["Cache-Control"] = "no-cache"
return response

6
django_ui/wsgi.py Normal file
View File

@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_ui.settings")
application = get_wsgi_application()