La Baselib è una libreria usata dal team di sviluppo della Direzione Sistema Informativo, sulla quale sono costruiti tutti i nuovi applicativi di backend.
Si basa a sua volta su Spring Boot e la sua implementazione è stata necessaria per standardizzare i processi di autenticazione, autorizzazione, i flussi di lavoro e la relativa documentazioni delle API REST.
Base configuration
Quella che segue è un esempio di configurazione application.yml
management: endpoints: web: exposure: include: "*" server: port: 8080 http: client: ssl: #these file are embedded in library trust-store: /service.jts trust-store-password: service it: infn: sisinfo: microservice: #microservices discovery strategy token: prefix: Bearer header: Authorization oidc: client: realm: ${OIDC_REALM:aai} auth-server: ${OIDC_SERVER:https://idp-test.app.infn.it/auth} client-id: ${SERVICE_ID:id} client-secret: ${SERVICE_SECRET:secret} enable: true
Service discovery
La Baselib ha un suo client interno basato su una personalizzazione del RestTemplare di Spring.
Tale client permette di chiamare una URL esterna oppure un alias.
Gli alias sono risolti in modo statico nella configurazione dell'applicazione, mediante le configurazioni sotto riportate:
it: infn: sisinfo: microservice: url-discovery-strategy: static url-discovery-map: (alias): (url) ...
Per poter comunicare con altri servizi, o URL esterne, chiamando API REST, occorre fare l'autowire della classe "ApiCommunication".
Questa espone vari metodi, che vanno dall'esecuzione di API REST all'upload di file:
Un elemento fondamentale in questi metodi e' il tipo di token definito dal tipo java "TokenType", avente due diversi valori che determinato quale token usare per l'autenticazione:
- TokenTypeApplication: la chiamata viene eseguita associando il token del servizio
- TokenTypeUser: la chiamata viene eseguita facendo il forward del token (in caso sia presente) dell'identità che correntemente autenticata.
È importante notare che le chiamate REST sono stateless. Ogni chiamata potrà quindi avere identità differenti e pertanto l'identità corrente non deve mai essere memorizzata nel controller o nel service, ma deve essere sempre richiesta alla libreria.
Cache
La Baselib permette di abilitare ed usare la cache per i dati richiesti più frequentemente.
Nello specifico vengono messe in cache le autorizzazioni della singola identità, laddove un API ne richiede il caricamento.
Per implementazione di backend viene usato Redis.
it: infn: sisinfo: microservice: cache: enabled: ${CACHE_ENABLED} expiration: ${CACHE_EXPIRATION} nodes: - hostname_1:port - hostname_2:port
La configurazione permette di associare uno o più nodi.
Nel caso risulti un solo nodo, viene configurata la cache in modalità stand-alone; in caso di nodi multipli, invece, avviene la configurazione in cluster.
Distribuited Queue Support
La Baselib implementa funzionalità di esecuzione in background di processi mediante l'utilizzo di Kafka.
Kafka permette l'esecuzione di tali processi tra più istanze dell'applicazione e ne garantisce l'alta affidabilità (es: in caso di failure il job viene rieseguito).
E' possibile attivare il supporto ai messaggi tramite le seguenti opzioni:
it: infn: sisinfo: microservice: queue: enabled: true server-address: ${QUEUE_SERVER_URL} client-id: ${QUEUE_CLIENT_ID} group-id: ${QUEUE_SERVER_ID}
Distributed Queue Job
E' possibile attivare anche l'esecuzione di batch job usando la seguente configurazione:
it: infn: sisinfo: microservice: queue-job-processing: enabled:true concurrency: ${QUEUE_JOB_PROCESSING_CONCURRENCY} topic: ${QUEUE_JOB_PROCESSING_QUEUE}
Distributed Behaviour
I job vengono schedulati su tutte le istanze del servizio con le stesse configurazioni
Search Utility Support
A volte e' necessario effettuare delle ricerche "globali" su più di un campo. Una best practice a riguardo è quella di far confluire su un unico campo tutti gli altri campi su cui si intende effettuare la ricerca.
In questo modo è possibile usare un unico indice e varie ulteriori funzionalità messe a disposizione dal database usato. A tale scopo la librerie mette a disposizione due annotation ed un "processore" di annotazioni contenute nel pacchetto "it.infn.sisinfo.microservice.corelib.search":
- @AggregatedSearchToken: Annotazione per indicare il field della classe destinazione degli aggregati.
- @AggregatedSearchTokenSource: annotazione che definisce i field della class da aggregare.
- @AggregatedSearchTokenProcessor: classe che espone il method public statico che permette di processare la classe annotata
Il funzionamento è di seguito esposto, prendendo ad esempio la classe:
public class Model { @AggregatedSearchTokenSource private String name = "John"; @AggregatedSearchTokenSource private String description = "Smith"; @AggregateSearchToken private String textSearch; }
Applicando ora ad un'istanza di tale classe il metodo del processore:
public class Model { @AggregatedSearchTokenSource private String name = "John"; @AggregatedSearchTokenSource private String description = "Smith"; @AggregateSearchToken private String textSearch; } private Model m = new Model(); AggregateSearchTokenProcessor.process(m, AggregateSearchTokenProcessor.AggregationType.SUBSTRING_TOKEN);
Sono previste le seguenti tipologie di aggregazione delle stringhe:
SUBSTRING_TOKEN
(default)SIMPLE_TOKEN
La SUBSTRING_TOKEN
estrae tutte le possibili substring di dimensione >= 3, a meno che la stringa non sia più corta, nel qual caso viene considerata interamente.
Nell'esempio qui sopra, ottengo che il filed "textSearch" verrà popolato nel modo seguente: textSearch → "Joh John ohn Smi Smit Smith mit mith ith".
La SIMPLE_TOKEN
prende i token di tutti i field annotati con AggregatedSearchTokenSource
(senza duplicazioni) e li aggiunge al field di destinazione, identificato con AggregateSearchToken
.
Autenticazione OpenID
La Baselib supporta l'autenticazione OpenID Connect (OIDC) tramite un server Keycloak
it: infn: sisinfo: microservice: oidc: client: enable: true realm: ${REALM} auth-server: ${KEYCLOACK_HOST} client-id: ${SERVICE_ID} client-secret: ${SERVICE_SECRET}
Autorizzazione
La Baselib permette di gestire in maniera semplice le autorizzazioni per l'accesso alle API. Inoltre consente di analizzare i parametri di un entitlement al fine di filtrare i dati di un resultset in base ai privilegi dell'identità autenticata.
Il controllo dell'autorizzazione viene eseguito prima ancora di arrivare all'esecuzione del metodo del controller.
I parametri di un entitlement possono essere descritti con la seguente modalità
urn:mace:infn.it:ent_with_parameter@param1:value1,param2:value2
seguono il carattere '@'
e sono separati dalla virgola ','
. Possono essere concatenati ad altri entitlement dello stesso tipo.
La baselib mette a disposizione utility per interrogare i parametri.
Security Access
Nella base lib ci sono due livelli di autorizzazione per eseguire una metodo del controller questi due livelli sono gestiti dall'annotaione @SecurityGuard. Permette di specificare tre parametri:
- accessLevel: permette di specificare se l'api identificata dal metodo è accessibile da tutti o solo previa autenticazione, tramite due costanti:
AccessLevelOpen, eseguibile in modo autenticato e non autenticato
AccessLevelToken, eseguibile solo se autenticati
- accessEntitlement (Opzionale): permette di associare un entitlement al metodo del rest controller
- authorizationGuard (Opzionale): permette di specificare una o più guardia da controllar prima che il metodo possa essere chiamato
Sotto viene riportato un esempio di una api pubblica, è sufficiente il token jwt per accedere all'API.:
@SecurityAccess( accessLevel = AccessLevelToken ) @PostMapping(path = "/find/by/tags") @Operation(summary = "Find all apps visible to the user with selected tags") public ApiResultResponse<List<ApplicationROInfoDTO>> findByTags( @Parameter(description = "List of tags") @RequestBody List<String> tags ) { return new ApiResultResponse<>(applicationService.findByTags(tags)); }
C'è la possibilità di dare accesso in base agli edupersonentitlement di AAI utilizzando il parametro accessEntitlement :
@SecurityAccess( accessLevel = AccessLevelToken, accessEntitlement = "appman:app_management_read" ) @GetMapping(path = "/get/management/data/by/{appID}") @Operation(summary = "Return the app management data") public ApiResultResponse<ApplicationManagementDTO> getAppManagementData( @Parameter(description = "Is unique id of application") @PathVariable String appID) { return new ApiResultResponse<>(applicationService.getAppManagementData(appID)); }
In questo caso è concesso l'accesso solo agli utenti che hanno il valore
urn:mace:infn.it:appman:app_management_read
Attenzione, se si usa la guardia di default (descritto sotto), inserire l'entitlement nell'annotation senza il prefisso urn:mace:infn.it: , perchè viene automaticante aggiunto dalla guardia in fase di controllo
Guardie
Le guardie possono essere associate sia al controller che ai metodi dei controller. Nel primo caso sono gestiti tutti i metodi che non specificano alcuna guardia. I metodi con le guardi sono gestiti solamente dal quelle a loro assegnati. Possono essere usate uno o più guardie il controllo viene eseguito dalla prima all'ultima. Accesso e' garantito quando una guardia ritorna 'true' ed il controllo termina senza eseguire le successive.
Di seguito un esempio di guardie globali o locali ai metodi:
@RestController @RequestMapping("/") @SecurityGuard={CustomGuard1.class, CustomGuard1.class, ...} public class DummyController { .... }
@GetMapping("/custom/guard/api") @SecurityAccess( accessLevel = SecurityAccess.AccessLevel.AccessLevelToken, authorizationGuards = {CustomGuard1.class, CustomGuard1.class, ...} ) public String customGuardRestApi()
Codificare una guardia
La guardia deve essere definita come un componente di Spring, in quanto la creazione viene gestita come uno spring Bean. Questo significa che e' possibile usare l'autowire di servizi e repository secondo le regole standard di Spring. La codifica consiste in una classe che estende la classe astratta 'AbstractAuthorizationGuard' che esporta il nome della guardia e il metodo astratto 'eval'.
abstract public class AbstractAuthorizationGuard { final private String name; public AbstractAuthorizationGuard(String name) { this.name = name; } /** * Is the abstract method for evaluate the guard class * @param entitlement is the entitlement associated to the method, if it has been set * @param restControllerName is the name of the controller in which the method belong * @param restMethodName is the name of the method that need to be checked * @param methodParameter are the method parameters * @return return true if the method is accessible for the guard instance * @throws Exception */ abstract boolean eval(String entitlement, String restControllerName, String restMethodName, MethodParameter[] methodParameter) throws Exception; }
il metodo eval ha tutti i parametri necessari per identificare il metodo di cui si deve decidere se l'identità corrente può o meno accedervi. Lo sviluppatore può usare le classi AuthorizationAccess e AuthorizationUtilService tramite l'autowire, per ottenere gli entitlement e i parametri dell'identità corrente e determinare in questo modo se il metodo può o meno essere autorizzato.
@Component public class CustomGuard extends AbstractAuthorizationGuard { private AuthorizationAccess authorizationAccess; private AuthorizationUtilService authorizationUtilService; public CustomGuard() { super("CustomGuard"); this.authorizationAccess = authorizationAccess; } @Override boolean eval( String entitlement, String restControllerName, String restMethodName, MethodParameter[] methodParameter) throws Exception { Authorization auth = authorizationAccess.getEntitlementForInfnUUID(authorizationAccess.getInfnUUID()); //YOUR CODE return false; } }
Log Enhancement
La baseline extend il log di spring aggiungendo due variabili che possono essere usate nel pattern di log, esse sono:
- x-b3-traceid: e' l'istanza dell'applicazione generata a startup
- x-b3-spanid: is the transaction id, that is an unique id created when a rest call is initiated
logging.pattern.level: "...app.inst.id=%X{x-b3-traceid} app.tx.id=%X{x-b3-spanid}..."
Mocking Authentication & Authorization
la base lib offre funzionalità per eseguire il mock dell'autenticazione e autorizzazione. Usando il profile "mock-auth" si abilita modifica il normale funzionamento del layer di autorizzazione delle rest e del servizio AuthorizationAccess per il controllo delle autorizzazioni dell'identità corrente. Sia l'autenticazione che le autorizzazioni sono prese direttamente dal token JWT create direttamente dalla base lib.
All'avvio dell'applicazione la baselib eseguire la configurazione della classe "MockIdentityConfig" la quale va a cercare il file "moked-identity.yml" per caricare instanze dell'oggetto MockedIdentity.
- infnUUID: uuid_1 issuer: issuer_1 label: "Utente 1" authentication: isMemberOf: - group_11 - group_12 schacUserStatus: eduPersonEntitlement: - ent_11 - ent_22 edupersonassurance: - infnUUID: uuid_2 issuer: issuer_2 label: "Utente 2" authentication: isMemberOf: - group_21 - group_22 schacUserStatus: eduPersonEntitlement: - ent_21 - ent_22 edupersonassurance:
public class MockIdentity { private String infnUUID; private String username; private String label; private Authorization authentication; private String issuer; private String accessToken; }
Inoltre viene aggiunto un nuovo controller che permette di interagire con le mocked identity. Per ora e' disponibile una sola api che permette di ritornare tutte le mocked identity "http://localhost:8080/v1/mock/auth/findAll", di seguito il risultato per il file di configurazione sopra riportato.
{ "errorCode": 0, "payload": [ { "accessToken": "eyJ...", "authentication": { "eduPersonEntitlement": [ "ent_11", "ent_22" ], "isMemberOf": [ "group_11", "group_12" ] }, "infnUUID": "uuid_1", "issuer": "issuer_1", "label": "Utente 1", "username": null }, { "accessToken": "eyJ...", "authentication": { "eduPersonEntitlement": [ "ent_21", "ent_22" ], "isMemberOf": [ "group_21", "group_22" ] }, "infnUUID": "uuid_2", "issuer": "issuer_2", "label": "Utente 2", "username": null } ] }
Mocking Async Action
A volte, durante lo sviluppo di una UI o dei test per il backend si ha la necessita di simulare eventi asynchorni che in un qualche modo vanno a processare gli stati dei documenti su cui si tassa lavorando. Per questa necessita si possono usare le "Mocked Action".
Per usare questa funzionalità bisogna impostare il profilo "mocked-action". Anche in questo caso la libreria andrà a cercare il file di configurazione "mocked-action.yml", il quale e' cosi definito:
- label: Action 1 type: job data: id: id jobImplementation: it.infn.microservice.Class jobParameter: id_conferenza: ${document_id} doc_id: value_2 - label: Action 2 type: job data: id: id jobImplementation: it.infn.microservice.Class jobParameter: doc_id: ${document_id}
Attualmente esiste una sola "azione" possible che e' quella di avviare Job asincroni di sotto la descrizione del costrutto yaml che descrive una singola azione:
label: Action 1 //e' lal abel che viene mostrata nella UI per identificare l'operazione che esegue l-azione type: job // descrive il tipo di azione "job" per ora 'e l'unico valore possibile data: // contiene la descriozne dell'azione di tipo job // il job Nell baseline e' definito da tre chiavi id, jobImplementation, jobParamter id: id // identificativo custom per controllo dei log jobImplementation: it.infn.microservice.Class // @Component di spring che identifica la casse da eseguire per il job jobParameter: // parametri che sono passati al job, che consiste in una mappa chiave valore id_conferenza: ${document_id} // in questo caso la chiave e' "id_conferenza" e cove valore e' // sta identificate una variabile ${document_id} descritta in seguito chiave_2: valore_2 // ulteriori chiavi possono essere aggiunte
Uso delle variabile
All'interno del file di descrizione un costrutto del tipo ${} viene definito come variabile. Questo tipo di costrutto verra riconosciuto dall INFN shell per creare le UI e verra usato per richiedere all'utente di inserire un valore da poter sostituire alla variabile stessa al momento del submit. Il contenuto delle variabile definire la label da usare a livello UI quando verra' richiesto di inserire un valore da associare alla variabile: es ${document_id} → document_id sarà usato come label per la form di richiesta dei valori delle variabili.
Astrazione per la gestione dell'autenticazione e delle autorizzazioni in chiamate REST
La baselib si basa su due interfacce per la gestione dello stato di autenticazione ed authorizzazion durante la chiamate di api rest: AuthenticationAccess e AuthorizationAccess ed IdentityAccess:
public interface AuthenticationAccess { /** * check the authentication status of current api call * @return */ boolean authenticated(); /** * Return the infn uuid * @return */ String getInfnUUID(); }
public interface AuthorizationAccess { /** * return the authorization of the current identity * @param infnUUID * @return */ Authorization getEntitlementForInfnUUID(String infnUUID) throws Exception; /** * * @return * @throws Exception */ Authorization getEntitlementForLoggedIdentity() throws Exception; }
/** * Abstraction for the base identity data access */ public interface IdentityAccess { /** * Return the identity default information for a specific infn uuid * @return */ Identity getIdentity(String infnUUID); /** * Return the identity default information for logged identity * @return */ Identity getIdentity(); }
La baselib offre differenti implementazioni delle tre, una per quando si usa il profile "mock-auth", l'altra quando non lo si usa. Usando il profile di mock tutte le implementazioni usano il JWT di mock, o le informazioni nel file di configurazione iml, per erogare le informazioni necessarie e nel caso non si usa il mock, la baselib mette a disposizione la sola implementazione, che usa il JWT , per l'AuthenticationAccess. Le applicazioni dovranno usare o un plugin o gestire autonomamente l'implementazione con component che implementa l'AuthorizationAccess e IdentityAccess.
Gestione delle autorizzazioni tramite identity-backend
E' stato realizzato il modulo identity-baselib-authorization che implementa l'interfaccia AuthorizationAccess usando le api rest di identity.
Async Controller
La baselib permette di definire una classe con l'annotazione @AsyncController in modo da poter ricevere chiamate, come nel caso di @RestController ma dal sottolivello di code invece che via REST.
Configurazione
La configurazione è la seguente:
microservice: queue: enabled: true queue-job-processing: enabled: true queue-message-processing: enabled: true concurrency: 1 reply-topic: 'reply-queue-name' queue-mapping: main-queue: 'queue-name' message-client: client-1: send-topic: 'queue-name'
il servizio di queue.enable e queue-job-processing.enable devono essere abilitati, i messaggi asincroni usano la sottoporre di Kafka per l'inoltro. di seguito la descrizione di ogni parametro:
- reply-topic: il nome della coda su cui il servizio remoto che riceve il messaggio asincrono deve rispondere. La risposta invaga sarà il risultato del metodo, non void, della classe
- queue-mapping: definisce una mappatura tra alias, da usare nel sorgente, e una coda di Kafka, gli alias sono usati negli asini controller per definire le code da cui prendere i messaggi
- message-client: definisce I client da usare, il primo livello dopo questo parametro definisce il nome del client, nei successivi livelli sono valorizzati i parametri di creazione dei client
- send-topic: definisce la topic su cui il client scrive
Definizione di un AsynController
@AsyncController( eventType = "eventTypeTest", queueAlias = "main-queue" ) public class AsyncController { @AsyncEvent(eventName = "voidTestEvent") public void voidTestEvent() throws InterruptedException { EventTest.eventResultQueue.put("voidTestEvent"); } @AsyncEvent(eventName = "voidTestParameterEvent") public void voidTestParameterEvent(EventTestParameter eventTestParameter) throws InterruptedException { EventTest.eventResultQueue.put(eventTestParameter); } @AsyncEvent(eventName = "replyTestParameterEvent") public EventTestParameter replyTestParameterEvent(EventTestParameter eventTestParameter) throws InterruptedException { EventTest.eventResultQueue.put(eventTestParameter); return eventTestParameter; } }
L'esempio sopra riportato definisce un AsyncController con le seguenti proprietà:
- eventType: é il tipo di evento a cui il controller deve rispondere
- queueAlias: è la cosa su cui attendere i messaggi di arrivo
Ogni method della classe che deve rispondere ad un certo event devono essere taggati con l'annotazione @AsyncEvent
- eventName: definisce il nome dell'evento, correlato al tipo definito in @AsyncController, da cui ne segue l'esecuzione del metodo
Il metodo può essere di tipo void o ritornare un risultato. IL risultato a sua volta può venir usato come reply al messaggio di esecuzione dell'evento
Client
La base lib definisce un spring service 'EventClientRegistry' che permette il fetch dei client definiti nella configurazione, ogni cliente mette a disposizione api per l'invio di chiamate asincrone agli AsyncController
public class EventMessageClient { private String name; private String topic; private String replyTopic; private KafkaTemplate<String, EventMessage> kafkaTemplate; public void send(String key, EventMessage request) throws ExecutionException, InterruptedException { kafkaTemplate.executeInTransaction( t -> t.send(topic, key,request) ); } /** * Push message and in the same transaction execute the inTransactionCallable lambda * @param key * @param eventType * @param eventName * @param data * @param inTransactionCallable * @param replyEventType * @param replyEventName * @return * @param <T> * @throws ExecutionException * @throws InterruptedException */ public <T> T send( String key, String eventType, String eventName, List<Object> data, Callable<T> inTransactionCallable, String replyEventType, String replyEventName) throws ExecutionException, InterruptedException { T res = kafkaTemplate.executeInTransaction( t -> { t.send( topic, key, EventMessage .builder() .eventName(eventName) .eventType(eventType) .data(data) .replyQueue(replyTopic) .replyEventType(replyEventType) .replyEventName(replyEventName) .build() ); try { return inTransactionCallable.call(); } catch (Exception e) { throw new RuntimeException(e); } } ); return res; } }
Entrambi i metodo si basano sull'oggetto EventMessage i cui campi sono:
- eventType: il tipo di messaggio da inviare
- eventName: il nome del messaggio che è correlato al metodo da eseguire
- data: una List<Object> che definisce i parametri del metodo (se il match non viene soddisfatto va in error la chiamata lato remoto)
- replyQueue: e la cosa su cio si vogliono attendere i reply(impostata in automatico dal client)
- replyEventType: definisce il tipo di evento su cui si vuole la risposta
- replyEventName: si definisce il nome dell'evento che seleziona il metodo da eseguire per il reply
Message Replay
Per il reply ad un messaggio i parametri replyEventType e replyEventName devono corrispondere ad un eventType ed eventName definiti da un @AsyncController definito nell'applicazione chiamate.
il method di reply deve avere come parametro il risultato del metodo remoto eseguito
@AsyncController(eventType = "asyncReply", queueAlias = "reply-topic") public class AsyncControllerReply { @AsyncEvent(eventName = "asyncReplyOne") public void reply(EventTestParameter eventTestParameter) throws InterruptedException { eventTestParameter.testData = eventTestParameter.testData+"reply"; EventTest.eventResultQueue.put(eventTestParameter); } }