Servidor REST: usuarios, multi-tenencia y métricas | eSeGeCe Blog

Servidor REST: usuarios, multi-tenencia y métricas

· Componentes
Usuarios, tenencia y métricas del servidor REST de sgcWebSockets

El primer artículo presentó TsgcHTTPRESTServer y su gestión de peticiones. Este cubre los tres componentes complementarios que convierten un endpoint funcional en algo que realmente puede poner delante de sus clientes: un almacén de usuarios, la multi-tenencia y las métricas que el equipo de operaciones va a pedir el primer día.

Los tres son componentes independientes. Cree los que necesite y asígnelos al servidor, y todo lo que deje sin asignar cuesta una sola comprobación de puntero por petición.

Un almacén de usuarios en lugar de un TStringList

TsgcHTTPServer_Users guarda las cuentas con hashes de contraseña iterados y con sal. Lo importante es lo poco que hay que conectar: asígnelo a las opciones de autenticación del servidor y la barrera HTTP Basic resuelve las credenciales contra él por sí sola.

uses
  sgcHTTP_REST_Server, sgcHTTP_REST_Server_Users;

FUsers := TsgcHTTPServer_Users.Create(self);
FServer := TsgcHTTPRESTServer.Create(self);
FServer.Authentication.Users := FUsers;

Eso es todo. No necesita un manejador OnAuthentication para que funcione la autenticación Basic, la barrera hace la búsqueda y la comparación del hash.

Las cuentas se añaden con AddUser, que devuelve el Id de usuario generado, o una cadena vacía cuando el nombre de usuario está en blanco o ya está en uso:

FUsers.AddUser('alice', 'secret123', 'admin,reader');
FUsers.AddUser('bob', 'secret456', 'reader');

El resto de la superficie es lo que cabe esperar, y todas las búsquedas son seguras entre hilos:

if FUsers.ValidateCredentials('alice', 'secret123') then
  ...

FUsers.SetPassword('bob', 'newsecret');
FUsers.EnableUser('bob', False);
FUsers.DeleteUser('bob');

Roles

Los roles son una lista separada por comas en la cuenta, con métodos auxiliares para leerlos y comprobarlos. Una barrera de rol en un manejador es una sola llamada:

FUsers.AddRole('bob', 'admin');
if not FUsers.UserHasRole(vUser, 'admin') then
begin
  AResponseInfo.ResponseNo := 403;
  AResponseInfo.ContentType := 'application/json';
  AResponseInfo.ContentText := '{"error":"forbidden"}';
  Exit;
end;

Hashing

Las contraseñas se procesan con hash SHA-512 a lo largo de 10000 iteraciones por defecto, y cada cuenta lleva su propia sal aleatoria. Esa sal por cuenta es lo que ya hace que dos contraseñas idénticas produzcan hashes distintos, así que rara vez necesitará tocar esto:

FUsers.Hashing.Algorithm := uhaSHA512;
FUsers.Hashing.Iterations := 10000;

La propiedad Salt es algo distinto y conviene leerla con atención. Es una pepper opcional que se mezcla en cada hash además de la sal de cada cuenta, y está vacía por defecto a propósito: un valor inventado en el momento de la construcción sería distinto en la siguiente ejecución, y entonces todas las contraseñas correctas se rechazarían sin ningún error que lo explicara. Establézcala solo cuando pueda venir de un sitio que no sea el propio almacén de usuarios, por ejemplo una variable de entorno o un almacén de claves. Una pepper guardada junto a los hashes que protege no aporta nada. Cambiarla invalida todas las contraseñas ya almacenadas.

Persistencia

El almacén está en memoria por defecto. Apúntelo a un archivo y sobrevivirá a un reinicio:

FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'sgcRESTUsers.dat';
FUsers.Storage.EncryptAtRest := True;
FUsers.Storage.EncryptionKey := GetKeyFromEnvironment;
FUsers.Storage.AutoSaveSeconds := 30;
FUsers.LoadUsers;

Con AutoSaveSeconds establecido, el almacén se vuelca periódicamente por sí solo; si no, llame a SaveUsers o SaveToFile cuando le convenga. Para un almacén que ya vive en su propia base de datos, ponga StorageType en ustCustom y responda a los eventos en su lugar, donde OnValidateCredentials pasa a ser la autoridad:

procedure TForm1.UsersValidateCredentials(Sender: TObject;
  const aUsername, aPassword: string; var Valid: Boolean);
begin
  Valid := MyDatabase.CheckLogin(aUsername, aPassword);
end;

Un detalle que importa si construye una pantalla de administración: GetUserByIndex vacía PasswordHash y Salt antes de devolver el registro, de modo que una ruta de enumeración no puede filtrar una credencial ni siquiera por accidente. FindUser no hace esto. Devuelve el registro exactamente como está almacenado, incluidos los campos de credenciales, porque es la búsqueda que utiliza la propia puerta de autenticación. Lea de él los campos que necesite y nunca serialice el registro completo en el cuerpo de una respuesta.

Multi-tenencia

TsgcHTTPServer_Tenancy responde a una pregunta por petición: ¿para qué cliente es esto? Resuelve una cadena de tenant antes de que se ejecute su manejador, y el servidor la expone en la propiedad de solo lectura Tenant.

FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FTenancy.HeaderName := 'X-Tenant-Id';
FTenancy.DefaultTenant := 'public';
FServer.Tenancy := FTenancy;

Hay cinco modos de resolución:

ResoluciónOrigenSe configura con
trNonedesactivado, Tenant siempre está vacío
trHostel nombre de hostHostSuffix
trPathun segmento de la ruta de la peticiónPathSegmentIndex
trHeaderuna cabecera de la peticiónHeaderName
trJWTClaimun claim del token bearerClaimName

Con trHost y HostSuffix establecido en .example.com, una petición a acme.example.com se resuelve como acme. Con trPath y un PathSegmentIndex de 0, /acme/api/orders se resuelve igual. Cuando el origen configurado no da nada, se usa DefaultTenant.

Leerlo en un manejador es simplemente una propiedad:

procedure TForm1.ServerCommandGet(AContext: TIdContext;
  ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
begin
  AResponseInfo.ResponseNo := 200;
  AResponseInfo.ContentType := 'application/json';
  AResponseInfo.ContentText := '{"tenant":"' + FServer.Tenant + '"}';
end;

Tenant es válido dentro de OnBeforeCommand, OnCommandGet y OnCommandOther, y es local al hilo, de forma que un servidor con mucha carga que atiende a muchos tenants a la vez nunca los mezcla.

Sobre trJWTClaim hay una cosa que debe quedar clara. El claim se lee del payload del token sin validar la firma, porque el tenant es solo una pista de enrutamiento. La firma la sigue comprobando la autenticación JWT cuando la habilita. No trate el tenant como prueba de identidad por sí solo.

Si ninguno de los cinco modos encaja, OnResolveTenant le entrega todos los orígenes a la vez y le deja decidir:

procedure TForm1.TenancyResolveTenant(Sender: TObject;
  const aHost, aPath, aHeaderValue, aJWTPayload: string; var aTenant: string);
begin
  aTenant := LookupTenantForHost(aHost);
end;

Métricas y salud

TsgcHTTPServerStats cuenta lo que ha hecho el servidor y puede publicarlo en dos endpoints. Ambos están desactivados hasta que los habilite individualmente, así que nada queda accesible solo por haber colocado el componente en el formulario.

FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Metrics.Enabled := True;
FStats.Endpoints.Health.Enabled := True;
FServer.ServerStats := FStats;

Los contadores también se pueden leer desde código, lo que resulta práctico para una pantalla de estado interna:

lblRequests.Caption := IntToStr(FStats.TotalRequests);
lblErrors.Caption := IntToStr(FStats.Status5xx);
lblLatency.Caption := IntToStr(FStats.LatencyAvgMs) + ' ms';
lblUptime.Caption := IntToStr(FStats.UptimeSeconds) + ' s';

/metrics responde directamente en el formato de exposición de texto de Prometheus, sin ningún exportador de por medio. Se incluyen contadores por endpoint, con la cardinalidad limitada a 256 rutas distintas para que una ruta con un id dentro no dispare el número de series. Todo lo que supera ese límite se agrupa bajo other.

# HELP sgc_server_endpoint_requests_total Requests per endpoint
# TYPE sgc_server_endpoint_requests_total counter
sgc_server_endpoint_requests_total{endpoint="/api/orders"} 3120
sgc_server_endpoint_requests_total{endpoint="/api/users"} 845

Si están conectados los componentes de firewall, limitador de tasa, cortacircuitos o gestor de claves de API, sus propias métricas se añaden a la misma salida:

FStats.RateLimiter := FRateLimiter;
FStats.Firewall := FFirewall;

/health responde con un documento JSON compacto. Su status indica ok, o degraded cuando hay un cortacircuitos conectado con circuitos abiertos, lo que lo hace directamente utilizable como sonda de un balanceador de carga.

Por último, OnStats se dispara con el objeto de estadísticas completo por si prefiere enviar los números a otro sitio usted mismo:

procedure TForm1.StatsEvent(Sender: TObject;
  const aStats: TsgcHTTPServerStats);
begin
  MyTelemetry.Send(aStats.TotalRequests, aStats.LatencyAvgMs);
end;

Todo junto

Un servidor con los tres conectados son alrededor de una docena de líneas, y cada componente sigue siendo independiente de los demás:

FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Health.Enabled := True;
FStats.Endpoints.Metrics.Enabled := True;

FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;

FUsers := TsgcHTTPServer_Users.Create(self);
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'users.dat';
FUsers.LoadUsers;

FServer := TsgcHTTPRESTServer.Create(self);
FServer.ServerStats := FStats;
FServer.Tenancy := FTenancy;
FServer.Authentication.Users := FUsers;
FServer.Authentication.Enabled := True;
FServer.Port := 5876;
FServer.Active := True;

El siguiente artículo pone un contrato OpenAPI delante de este servidor, de manera que el enrutamiento, la validación y la documentación salen todos de la especificación.

Descargue la última versión desde la página de descargas de sgcWebSockets.