From ca7cda86882d069fb72a557159dec6a2c6430922 Mon Sep 17 00:00:00 2001 From: ertopogo Date: Fri, 13 Mar 2026 01:06:47 +0100 Subject: Add local media-access-api service and switch compose to local build --- .env.photoprism-secure.example | 2 +- MEDIA_ACCESS_API.md | 7 ++ compose.photoprism-secure.dev.yml | 5 +- docs/CHANGELOG_OPERATIONS.md | 1 + docs/DEPLOIEMENT_KONENPAN.md | 4 + docs/INSTALLATION.md | 3 +- docs/TROUBLESHOOTING.md | 15 ++++ media-access-api/.dockerignore | 2 + media-access-api/Dockerfile | 13 ++++ media-access-api/package.json | 19 +++++ media-access-api/server.js | 150 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 media-access-api/.dockerignore create mode 100644 media-access-api/Dockerfile create mode 100644 media-access-api/package.json create mode 100644 media-access-api/server.js diff --git a/.env.photoprism-secure.example b/.env.photoprism-secure.example index 0d87242..411aa76 100644 --- a/.env.photoprism-secure.example +++ b/.env.photoprism-secure.example @@ -16,7 +16,7 @@ S3_BUCKET=medias-private S3_REGION=us-east-1 # API d'acces media -MEDIA_ACCESS_API_IMAGE=ghcr.io/your-org/media-access-api:latest +MEDIA_ACCESS_API_IMAGE=local/media-access-api:dev PRESIGN_TTL_SECONDS=120 # Viewer BFF diff --git a/MEDIA_ACCESS_API.md b/MEDIA_ACCESS_API.md index ec5598e..99b71aa 100644 --- a/MEDIA_ACCESS_API.md +++ b/MEDIA_ACCESS_API.md @@ -20,6 +20,7 @@ Definir une couche d'autorisation entre viewer-bff et MinIO afin d'appliquer les - `S3_BUCKET=medias-private` - `S3_FORCE_PATH_STYLE=true` - `PRESIGN_TTL_SECONDS=120` +- `MEDIA_ACCESS_API_IMAGE=local/media-access-api:dev` (tag local conseille pour Compose) ## Contrat API propose @@ -76,3 +77,9 @@ Definir une couche d'autorisation entre viewer-bff et MinIO afin d'appliquer les - Logger `subject`, `objectKey`, decision (`allow`/`deny`), raison, `requestId`. - Ne jamais logger un token complet ni des secrets. - Exporter des metriques de refus ACL pour detection d'erreurs de mapping. + +## Build local (recommande) +La stack est prevue pour construire `media-access-api` localement depuis le dossier `media-access-api/`: +```bash +docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml up -d --build +``` diff --git a/compose.photoprism-secure.dev.yml b/compose.photoprism-secure.dev.yml index 6cdfc9a..33017cc 100644 --- a/compose.photoprism-secure.dev.yml +++ b/compose.photoprism-secure.dev.yml @@ -36,7 +36,10 @@ services: - medias_net media-access-api: - image: ${MEDIA_ACCESS_API_IMAGE} + image: ${MEDIA_ACCESS_API_IMAGE:-local/media-access-api:dev} + build: + context: ./media-access-api + dockerfile: Dockerfile container_name: medias-access-api environment: OIDC_ISSUER: ${OIDC_ISSUER} diff --git a/docs/CHANGELOG_OPERATIONS.md b/docs/CHANGELOG_OPERATIONS.md index 6cb0d85..6d4f43d 100644 --- a/docs/CHANGELOG_OPERATIONS.md +++ b/docs/CHANGELOG_OPERATIONS.md @@ -34,3 +34,4 @@ Chaque action d'installation, configuration ou troubleshooting doit ajouter une | 2026-03-08 | install | viewer-bff | Ajout backend Node/Express + UI POC pour consultation via `media-access-api` | viewer-bff/, VIEWER_BFF.md, compose.photoprism-secure.dev.yml, .env.photoprism-secure.example, INSTALLATION.md, CONFIGURATION.md, TROUBLESHOOTING.md, INTEGRATIONS.md, CADDY_ARAUCARIA.md, DECISIONS.md, MEDIA_ACCESS_API.md | OK | equipe-plateforme | | 2026-03-08 | install | deploiement konenpan | Ajout runbook de transfert/release/rollback pour poser les fichiers sur la VM | docs/DEPLOIEMENT_KONENPAN.md, INSTALLATION.md | OK | equipe-plateforme | | 2026-03-08 | install | git relay chillka | Standardisation du flux Antel -> chillka bare repo -> konenpan avec branches `main` + `develop` | docs/DEPLOIEMENT_KONENPAN.md, INSTALLATION.md | OK | equipe-plateforme | +| 2026-03-08 | install | media-access-api local | Ajout du service `media-access-api` (code + Dockerfile) et bascule du compose en build local sans dependance GHCR | media-access-api/, compose.photoprism-secure.dev.yml, .env.photoprism-secure.example, MEDIA_ACCESS_API.md, INSTALLATION.md, TROUBLESHOOTING.md, DEPLOIEMENT_KONENPAN.md | OK | equipe-plateforme | diff --git a/docs/DEPLOIEMENT_KONENPAN.md b/docs/DEPLOIEMENT_KONENPAN.md index 4adceb6..71dab66 100644 --- a/docs/DEPLOIEMENT_KONENPAN.md +++ b/docs/DEPLOIEMENT_KONENPAN.md @@ -69,6 +69,10 @@ nano .env.dev docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml up -d --build ``` +Important: +- Le dossier `media-access-api/` doit etre present dans le repo clone (code + Dockerfile). +- Le service est construit localement par Docker Compose, aucune image GHCR n'est requise pour demarrer. + ## Cycle de mise a jour Sur Antel: ```bash diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 323622d..7c3e5b8 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -67,6 +67,7 @@ Documenter une installation reproductible de la plateforme medias. ### Variables et secrets - Fichier source des variables: `.env.dev` (non versionne) - Secrets requis: `OIDC_CLIENT_SECRET`, `MINIO_ROOT_PASSWORD` +- Note: `media-access-api` est build localement depuis `./media-access-api` (pas de dependance obligatoire a un registry externe). ### Procedure d'installation (exemple) 1. Deployer les fichiers sur `konenpan` via le depot Git relay `chillka` selon `docs/DEPLOIEMENT_KONENPAN.md`. @@ -82,7 +83,7 @@ Documenter une installation reproductible de la plateforme medias. Commandes de reference: ```bash cp .env.photoprism-secure.example .env.dev -docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml up -d +docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml up -d --build docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml ps ``` diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 58d72a2..e05cc03 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -50,6 +50,7 @@ Interpretation rapide: | Timeout | Service indisponible | Healthcheck et reseau | Redemarrer service / corriger reseau | | Token invalide | Mauvaise config OIDC | Verifier issuer/audience | Corriger configuration client | | 401 sur viewer-bff | Token absent/non transfere | Verifier header Authorization | Rejouer avec Bearer token valide | +| `denied` pull image `media-access-api` | Image distante invalide/inaccessible | Verifier `MEDIA_ACCESS_API_IMAGE` et dossier `media-access-api/` | Utiliser build local compose et relancer avec `--build` | | Heure incoherente | NTP non synchronise | `timedatectl`, `chronyc tracking` | Configurer chrony vers `araucaria` | | Nom DNS local non resolu | Record dnsmasq absent/invalide | `dig +short .arauco.online` | Corriger fichier dnsmasq puis restart service | | HTTPS KO via domaine | Caddy non charge / vhost invalide | `caddy validate`, `journalctl -u caddy` | Corriger Caddyfile puis reload Caddy | @@ -138,3 +139,17 @@ Interpretation rapide: 1. Corriger le bloc de vhost dans Caddyfile. 2. Recharger: `sudo systemctl reload caddy`. 3. Rejouer le test HTTPS. + +## Cas pratique: erreur `denied` sur `media-access-api` +### Contexte +- Symptome: `docker compose up` echoue avec `Head https://ghcr.io/... denied`. + +### Diagnostic +1. Verifier la variable: `grep MEDIA_ACCESS_API_IMAGE .env.dev`. +2. Verifier la presence du code local: `ls -la media-access-api`. +3. Verifier compose: `media-access-api` doit avoir un bloc `build`. + +### Correction appliquee +1. Garder/mettre `MEDIA_ACCESS_API_IMAGE=local/media-access-api:dev`. +2. S'assurer que `media-access-api/` contient `Dockerfile` + code. +3. Relancer: `docker compose --env-file .env.dev -f compose.photoprism-secure.dev.yml up -d --build`. diff --git a/media-access-api/.dockerignore b/media-access-api/.dockerignore new file mode 100644 index 0000000..3abe3d4 --- /dev/null +++ b/media-access-api/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log diff --git a/media-access-api/Dockerfile b/media-access-api/Dockerfile new file mode 100644 index 0000000..04d4f1e --- /dev/null +++ b/media-access-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV PORT=8081 +EXPOSE 8081 + +CMD ["npm", "start"] diff --git a/media-access-api/package.json b/media-access-api/package.json new file mode 100644 index 0000000..afcd2aa --- /dev/null +++ b/media-access-api/package.json @@ -0,0 +1,19 @@ +{ + "name": "media-access-api", + "version": "0.1.0", + "private": true, + "description": "API ACL medias (JWT Keycloak + MinIO presign)", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.879.0", + "@aws-sdk/s3-request-presigner": "^3.879.0", + "express": "^4.21.2", + "jose": "^5.10.0" + } +} diff --git a/media-access-api/server.js b/media-access-api/server.js new file mode 100644 index 0000000..bed038f --- /dev/null +++ b/media-access-api/server.js @@ -0,0 +1,150 @@ +const express = require("express"); +const { + S3Client, + HeadObjectCommand, + GetObjectCommand +} = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { createRemoteJWKSet, jwtVerify } = require("jose"); + +const app = express(); +app.use(express.json({ limit: "1mb" })); + +const PORT = Number(process.env.PORT || 8081); +const OIDC_ISSUER = process.env.OIDC_ISSUER; +const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE; +const OIDC_JWKS_URL = process.env.OIDC_JWKS_URL; +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || OIDC_AUDIENCE; +const RBAC_ROLE_PREFIX = process.env.RBAC_ROLE_PREFIX || "media_reader:folder:"; +const RBAC_ROLE_ALL = process.env.RBAC_ROLE_ALL || "media_reader:all"; +const S3_BUCKET = process.env.S3_BUCKET; +const PRESIGN_TTL_SECONDS = Number(process.env.PRESIGN_TTL_SECONDS || 120); + +if (!OIDC_ISSUER || !OIDC_AUDIENCE || !OIDC_JWKS_URL || !S3_BUCKET) { + // eslint-disable-next-line no-console + console.error("Configuration manquante: OIDC_ISSUER/OIDC_AUDIENCE/OIDC_JWKS_URL/S3_BUCKET"); + process.exit(1); +} + +const jwks = createRemoteJWKSet(new URL(OIDC_JWKS_URL)); + +const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION || "us-east-1", + forcePathStyle: String(process.env.S3_FORCE_PATH_STYLE || "true") === "true", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY || "", + secretAccessKey: process.env.S3_SECRET_KEY || "" + } +}); + +function extractBearerToken(req) { + const h = req.headers.authorization || ""; + if (!h.startsWith("Bearer ")) return null; + return h.slice("Bearer ".length).trim(); +} + +function getClientRoles(payload) { + const byClient = payload?.resource_access?.[OIDC_CLIENT_ID]?.roles; + if (Array.isArray(byClient)) return byClient; + const byAudience = payload?.resource_access?.[OIDC_AUDIENCE]?.roles; + if (Array.isArray(byAudience)) return byAudience; + return []; +} + +function computePermissions(roles) { + const allowAll = roles.includes(RBAC_ROLE_ALL); + const allowedPrefixes = allowAll + ? [] + : roles + .filter((r) => r.startsWith(RBAC_ROLE_PREFIX)) + .map((r) => `${r.slice(RBAC_ROLE_PREFIX.length).replace(/^\/+/, "")}/`); + return { allowAll, allowedPrefixes }; +} + +function isObjectAllowed(objectKey, permissions) { + if (permissions.allowAll) return true; + return permissions.allowedPrefixes.some((p) => objectKey.startsWith(p)); +} + +async function requireAuth(req, res, next) { + const token = extractBearerToken(req); + if (!token) { + return res.status(401).json({ message: "Bearer token requis" }); + } + + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: OIDC_ISSUER, + audience: OIDC_AUDIENCE + }); + + const roles = getClientRoles(payload); + const permissions = computePermissions(roles); + + req.user = { + sub: payload.sub || "unknown", + roles, + permissions + }; + return next(); + } catch (_err) { + return res.status(401).json({ message: "Token invalide" }); + } +} + +app.get("/health", (_req, res) => { + res.json({ + status: "ok", + service: "media-access-api", + audience: OIDC_AUDIENCE + }); +}); + +app.get("/v1/permissions", requireAuth, (req, res) => { + res.json({ + subject: req.user.sub, + allowAll: req.user.permissions.allowAll, + allowedPrefixes: req.user.permissions.allowedPrefixes + }); +}); + +app.post("/v1/presign", requireAuth, async (req, res) => { + const objectKey = req.body?.objectKey; + if (!objectKey || typeof objectKey !== "string") { + return res.status(400).json({ message: "Champ objectKey requis" }); + } + + if (!isObjectAllowed(objectKey, req.user.permissions)) { + return res.status(403).json({ message: "Object non autorise" }); + } + + try { + await s3.send( + new HeadObjectCommand({ + Bucket: S3_BUCKET, + Key: objectKey + }) + ); + } catch (_err) { + return res.status(404).json({ message: "Object introuvable" }); + } + + try { + const command = new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: objectKey + }); + const url = await getSignedUrl(s3, command, { + expiresIn: PRESIGN_TTL_SECONDS + }); + return res.json({ url, expiresIn: PRESIGN_TTL_SECONDS }); + } catch (_err) { + return res.status(500).json({ message: "Erreur generation URL signee" }); + } +}); + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`media-access-api listening on ${PORT}`); +}); -- cgit v1.2.3