Fix Generali

This commit is contained in:
Lorenzo Sanesi
2026-07-25 18:40:29 +02:00
parent 1326a3c887
commit 5855352c8c
11 changed files with 171 additions and 21 deletions
+26 -7
View File
@@ -78,6 +78,8 @@ services:
KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT}
ORG_SERVICE_API_BASE_URL: http://localhost:${HOME_BACKEND_PORT}
ATTIVITA_FE_BASE_URL: http://localhost:${ATTIVITA_FRONTEND_PORT}
MAGAZZINO_FE_BASE_URL: http://localhost:${MAGAZZINO_FRONTEND_PORT}
EVENTI_FE_BASE_URL: http://localhost:${EVENTI_FRONTEND_PORT}
restart: unless-stopped
ports:
- "${HOME_FRONTEND_PORT}:80"
@@ -160,13 +162,11 @@ services:
keycloak:
image: quay.io/keycloak/keycloak:26.7
restart: unless-stopped
# --import-realm importa keycloak/realm-export.json solo se il realm non esiste già
# NOTA: dopo l'import, assegnare al service account di scouthub-home-be i client role
# del client 'realm-management': manage-organizations, manage-users, manage-groups,
# view-users (permessi minimi, non manage-realm). Con Fine-Grained Admin Permissions
# (26.7+, feature admin-fine-grained-authz:v1) si puo' poi restringere ulteriormente
# per singola Organization.
command: start-dev --import-realm
# --import-realm importa keycloak/realm-export.json solo se il realm non esiste già.
# Le correzioni post-import (ruoli service account, protocol mapper "organization") non
# coperte dal file JSON sono automatizzate dal servizio one-shot "keycloak-init" sotto,
# non più manuali (vedi CLAUDE.md).
command: start-dev --import-realm --health-enabled=true
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
@@ -187,6 +187,25 @@ services:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
depends_on:
- keycloak-db
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '\"status\": \"UP\"' <&3"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
keycloak-init:
image: alpine:3.20
restart: "no"
entrypoint: ["sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /init-post-import.sh"]
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
volumes:
- ./keycloak/init-post-import.sh:/init-post-import.sh:ro
depends_on:
keycloak:
condition: service_healthy
volumes:
scouthub_db_data:
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
# Corregge due impostazioni che l'import di realm-export.json non copre (vedi CLAUDE.md):
# 1. il client-scope built-in "organization" (creato da Keycloak con organizationsEnabled:true,
# non presente nel JSON) di default non include l'id dell'org nel claim JWT
# 2. il service account di scouthub-home-be non ha i client role di realm-management necessari
# a operare sulla Admin API (organizations, users, roles)
#
# Idempotente: eseguibile ad ogni avvio senza effetti collaterali se già a posto.
set -eu
KEYCLOAK_URL="http://keycloak:8080"
REALM="scouthub"
SA_CLIENT_ID="scouthub-home-be"
REQUIRED_REALM_MANAGEMENT_ROLES="manage-organizations manage-users view-realm view-users"
echo "[keycloak-init] attendo che $KEYCLOAK_URL sia pronto..."
until curl -sf "$KEYCLOAK_URL/realms/master/.well-known/openid-configuration" >/dev/null 2>&1; do
sleep 2
done
echo "[keycloak-init] attendo che il realm '$REALM' sia importato..."
until curl -sf "$KEYCLOAK_URL/realms/$REALM/.well-known/openid-configuration" >/dev/null 2>&1; do
sleep 2
done
get_admin_token() {
curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=$KEYCLOAK_ADMIN" \
-d "password=$KEYCLOAK_ADMIN_PASSWORD" \
-d "grant_type=password" | jq -r '.access_token'
}
TOKEN="$(get_admin_token)"
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "[keycloak-init] impossibile ottenere il token admin, esco." >&2
exit 1
fi
auth_get() { curl -sf -H "Authorization: Bearer $TOKEN" "$KEYCLOAK_URL$1"; }
# --- 1. client-scope "organization": abilita addOrganizationId -------------------------------
SCOPE_ID="$(auth_get "/admin/realms/$REALM/client-scopes" | jq -r '.[] | select(.name=="organization") | .id')"
if [ -z "$SCOPE_ID" ]; then
echo "[keycloak-init] client-scope 'organization' non trovato, salto." >&2
else
MAPPER_JSON="$(auth_get "/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models" \
| jq -c '.[] | select(.name=="organization")')"
MAPPER_ID="$(echo "$MAPPER_JSON" | jq -r '.id')"
ALREADY_SET="$(echo "$MAPPER_JSON" | jq -r '.config.addOrganizationId // "false"')"
if [ "$ALREADY_SET" = "true" ]; then
echo "[keycloak-init] mapper 'organization': addOrganizationId già attivo."
else
UPDATED_MAPPER="$(echo "$MAPPER_JSON" | jq '.config.addOrganizationId = "true"')"
curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$KEYCLOAK_URL/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models/$MAPPER_ID" \
-d "$UPDATED_MAPPER" >/dev/null
echo "[keycloak-init] mapper 'organization': addOrganizationId attivato."
fi
fi
# --- 2. service account di scouthub-home-be: ruoli realm-management ---------------------------
SA_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=$SA_CLIENT_ID" | jq -r '.[0].id')"
if [ -z "$SA_UUID" ] || [ "$SA_UUID" = "null" ]; then
echo "[keycloak-init] client '$SA_CLIENT_ID' non trovato, salto." >&2
else
SA_USER_ID="$(auth_get "/admin/realms/$REALM/clients/$SA_UUID/service-account-user" | jq -r '.id')"
RM_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=realm-management" | jq -r '.[0].id')"
GIA_ASSEGNATI="$(auth_get "/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" | jq -r '.[].name')"
DISPONIBILI="$(auth_get "/admin/realms/$REALM/clients/$RM_UUID/roles")"
DA_ASSEGNARE="[]"
for ROLE in $REQUIRED_REALM_MANAGEMENT_ROLES; do
if echo "$GIA_ASSEGNATI" | grep -qx "$ROLE"; then
continue
fi
ROLE_REPR="$(echo "$DISPONIBILI" | jq -c --arg name "$ROLE" '.[] | select(.name==$name)')"
if [ -z "$ROLE_REPR" ]; then
echo "[keycloak-init] ruolo '$ROLE' non esiste su questa versione di Keycloak, salto." >&2
continue
fi
DA_ASSEGNARE="$(echo "$DA_ASSEGNARE" | jq -c --argjson r "$ROLE_REPR" '. + [$r]')"
done
if [ "$(echo "$DA_ASSEGNARE" | jq 'length')" = "0" ]; then
echo "[keycloak-init] service account '$SA_CLIENT_ID': ruoli realm-management già a posto."
else
curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$KEYCLOAK_URL/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" \
-d "$DA_ASSEGNARE" >/dev/null
echo "[keycloak-init] service account '$SA_CLIENT_ID': assegnati $(echo "$DA_ASSEGNARE" | jq -r '[.[].name] | join(", ")')."
fi
fi
echo "[keycloak-init] completato."
@@ -79,9 +79,18 @@ export async function addMemberToOrganization(orgId: string, userId: string): Pr
// Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede
// Content-Type application/json con il solo id utente come stringa JSON nel
// body (non un oggetto, e non text/plain: entrambi rispondono 415).
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
headers: { 'Content-Type': 'application/json' },
});
try {
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
// Idempotente: un retry dopo un fallimento parziale (es. l'assegnazione
// del ruolo realm successiva fallita) non deve rompersi solo perché
// l'utente era già stato aggiunto all'org nel tentativo precedente.
if (!axios.isAxiosError(err) || err.response?.status !== 409) {
throw err;
}
}
}
export interface OrganizationMember {
@@ -1,9 +1,10 @@
import { assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
import { assignRealmRoleToUser } from '../keycloak-admin';
// "ruolo" è usato qui anche come identificativo del gruppo Keycloak, in
// attesa che createOrganizationGroup persista una mappa ruolo -> groupId
// reale da risolvere in questo punto (vedi gruppi.service.ts).
// "ruolo" qui è sempre un ruolo realm (capo-gruppo/capo-unita/capo/censito/...),
// non un gruppo Keycloak: il gruppo interno all'organizzazione (Capi/Aiuto
// capi/Censiti, creato da createOrganizationGroup) è un concetto distinto,
// oggi non collegato a nessun input utente, e va assegnato a parte quando
// servirà davvero.
export async function assegnaGruppoERuolo(userId: string, ruolo: string): Promise<void> {
await assignUserToGroup(userId, ruolo);
await assignRealmRoleToUser(userId, ruolo);
}
+4
View File
@@ -3,6 +3,8 @@ FROM node:22-bookworm-slim AS build
ARG KEYCLOAK_BASE_URL
ARG ORG_SERVICE_API_BASE_URL
ARG ATTIVITA_FE_BASE_URL
ARG MAGAZZINO_FE_BASE_URL
ARG EVENTI_FE_BASE_URL
WORKDIR /app
@@ -14,6 +16,8 @@ RUN sed -i \
-e "s#__KEYCLOAK_BASE_URL__#${KEYCLOAK_BASE_URL}#" \
-e "s#__ORG_SERVICE_API_BASE_URL__#${ORG_SERVICE_API_BASE_URL}#" \
-e "s#__ATTIVITA_FE_BASE_URL__#${ATTIVITA_FE_BASE_URL}#" \
-e "s#__MAGAZZINO_FE_BASE_URL__#${MAGAZZINO_FE_BASE_URL}#" \
-e "s#__EVENTI_FE_BASE_URL__#${EVENTI_FE_BASE_URL}#" \
src/environments/environment.ts
RUN npm run build
@@ -143,6 +143,7 @@
@if (canManageMembri) {
<div class="gd-panel gd-panel--inviti">
<section class="gruppo-dettaglio__invito gd-card">
<h2 class="gd-card__title">Invita per email</h2>
<p class="gruppo-dettaglio__hint">
La persona invitata riceverà un link per completare l'accesso: se non ha ancora un account su Scouthub
dovrà accedere o registrarsi al primo utilizzo del link, il flusso di accettazione dell'invito se ne
@@ -402,12 +402,12 @@ describe('GruppoDettaglio', () => {
expect(compiled.querySelector('.gruppo-dettaglio__richieste-ingresso')).toBeNull();
});
it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => {
it('mostra "Nessuna richiesta in sospeso" se la lista è vuota', async () => {
await setup({ roles: ['admin'], richieste: [] });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna richiesta in attesa');
expect(compiled.textContent).toContain('Nessuna richiesta in sospeso');
});
it('mostra un errore se il caricamento fallisce', async () => {
+14
View File
@@ -22,6 +22,20 @@ const SERVIZI: ServizioCard[] = [
descrizione: 'Catalogo e ricerca delle attività scout',
url: environment.attivitaFeBaseUrl,
messaggioAccesso: 'Accedi per poter aggiungere attività'
},
{
id: 'magazzino',
nome: 'Scouthub Magazzino',
descrizione: 'Gestione materiali, liste ed eventi del magazzino',
url: environment.magazzinoFeBaseUrl,
messaggioAccesso: 'Accedi per gestire il magazzino del tuo gruppo'
},
{
id: 'eventi',
nome: 'Scouthub Eventi',
descrizione: 'Calendario delle attività ed eventi per branca',
url: environment.eventiFeBaseUrl,
messaggioAccesso: 'Accedi per gestire gli eventi del tuo gruppo'
}
];
@@ -69,12 +69,12 @@ describe('RichiesteCreazioneGruppo', () => {
expect(compiled.textContent).toContain('Lazio');
});
it('mostra "Nessuna richiesta in attesa" se la lista è vuota', async () => {
it('mostra "Nessuna richiesta in sospeso" se la lista è vuota', async () => {
await setup({ roles: ['admin'], richieste: [] });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna richiesta in attesa');
expect(compiled.textContent).toContain('Nessuna richiesta in sospeso');
});
it('mostra un errore se il caricamento fallisce', async () => {
@@ -4,5 +4,7 @@ export const environment = {
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
orgServiceApiBaseUrl: 'http://localhost:8000',
attivitaFeBaseUrl: 'http://localhost:7001'
attivitaFeBaseUrl: 'http://localhost:7001',
magazzinoFeBaseUrl: 'http://localhost:7002',
eventiFeBaseUrl: 'http://localhost:7003'
};
@@ -4,5 +4,7 @@ export const environment = {
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
orgServiceApiBaseUrl: '__ORG_SERVICE_API_BASE_URL__',
attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__'
attivitaFeBaseUrl: '__ATTIVITA_FE_BASE_URL__',
magazzinoFeBaseUrl: '__MAGAZZINO_FE_BASE_URL__',
eventiFeBaseUrl: '__EVENTI_FE_BASE_URL__'
};