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 --- media-access-api/.dockerignore | 2 + media-access-api/Dockerfile | 13 ++++ media-access-api/package.json | 19 ++++++ media-access-api/server.js | 150 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) 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 (limited to 'media-access-api') 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