summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.photoprism-secure.example2
-rw-r--r--MEDIA_ACCESS_API.md7
-rw-r--r--compose.photoprism-secure.dev.yml5
-rw-r--r--docs/CHANGELOG_OPERATIONS.md1
-rw-r--r--docs/DEPLOIEMENT_KONENPAN.md4
-rw-r--r--docs/INSTALLATION.md3
-rw-r--r--docs/TROUBLESHOOTING.md15
-rw-r--r--media-access-api/.dockerignore2
-rw-r--r--media-access-api/Dockerfile13
-rw-r--r--media-access-api/package.json19
-rw-r--r--media-access-api/server.js150
11 files changed, 218 insertions, 3 deletions
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 <host>.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}`);
+});