The first article introduced TsgcHTTPRESTServer and its request handling. This one covers the three companion components that turn a working endpoint into something you can actually put in front of customers: a user store, multi-tenancy, and the metrics the operations team will ask for on day one.
All three are separate components. You create the ones you need and assign them to the server, and anything you leave unassigned costs a single pointer test per request.
A user store instead of a TStringList
TsgcHTTPServer_Users keeps accounts with salted, iterated password hashes. The important part is how little wiring it takes: assign it to the server's authentication options and the HTTP Basic gate resolves credentials against it by itself.
uses
sgcHTTP_REST_Server, sgcHTTP_REST_Server_Users;
FUsers := TsgcHTTPServer_Users.Create(self);
FServer := TsgcHTTPRESTServer.Create(self);
FServer.Authentication.Users := FUsers;
That is all of it. You do not need an OnAuthentication handler for Basic authentication to work, the gate does the lookup and the hash comparison.
Accounts are added with AddUser, which returns the generated user Id, or an empty string when the username is blank or already taken:
FUsers.AddUser('alice', 'secret123', 'admin,reader');
FUsers.AddUser('bob', 'secret456', 'reader');
The rest of the surface is what you would expect, and every lookup is thread safe:
if FUsers.ValidateCredentials('alice', 'secret123') then
...
FUsers.SetPassword('bob', 'newsecret');
FUsers.EnableUser('bob', False);
FUsers.DeleteUser('bob');
Roles
Roles are a comma separated list on the account, with helpers to read and test them. A role gate in a handler is a single call:
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
Passwords are hashed with SHA-512 over 10000 iterations by default, each account carrying its own random salt. That per-account salt is what already makes two identical passwords hash differently, so you rarely need to touch this:
FUsers.Hashing.Algorithm := uhaSHA512;
FUsers.Hashing.Iterations := 10000;
The Salt property is something different and worth reading carefully. It is an optional pepper mixed into every hash on top of the per-account salt, and it is empty by default on purpose: a value invented at construction time would differ on the next run, and every correct password would then be rejected with no error to explain it. Set it only when it can come from somewhere the user store itself is not, an environment variable or a key vault for example. A pepper kept beside the hashes it protects adds nothing. Changing it invalidates every password already stored.
Persistence
The store is in memory by default. Point it at a file and it survives a restart:
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'sgcRESTUsers.dat';
FUsers.Storage.EncryptAtRest := True;
FUsers.Storage.EncryptionKey := GetKeyFromEnvironment;
FUsers.Storage.AutoSaveSeconds := 30;
FUsers.LoadUsers;
With AutoSaveSeconds set the store flushes itself periodically; otherwise call SaveUsers or SaveToFile when it suits you. For a store that already lives in your own database, set StorageType to ustCustom and answer the events instead, where OnValidateCredentials becomes authoritative:
procedure TForm1.UsersValidateCredentials(Sender: TObject;
const aUsername, aPassword: string; var Valid: Boolean);
begin
Valid := MyDatabase.CheckLogin(aUsername, aPassword);
end;
One detail that matters if you build an admin screen: GetUserByIndex blanks PasswordHash and Salt before handing the record back, so an enumeration route cannot leak a credential even by accident. FindUser does not do this. It returns the record exactly as stored, credential fields included, because it is the lookup the authentication gate itself uses. Read the fields you need from it, and never serialize the whole record into a response body.
Multi-tenancy
TsgcHTTPServer_Tenancy answers one question per request: which customer is this for? It resolves a tenant string before your handler runs, and the server exposes it on the read-only Tenant property.
FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FTenancy.HeaderName := 'X-Tenant-Id';
FTenancy.DefaultTenant := 'public';
FServer.Tenancy := FTenancy;
There are five resolution modes:
| Resolution | Source | Configured with |
|---|---|---|
trNone | off, Tenant is always empty | — |
trHost | the host name | HostSuffix |
trPath | a segment of the request path | PathSegmentIndex |
trHeader | a request header | HeaderName |
trJWTClaim | a claim of the bearer token | ClaimName |
With trHost and HostSuffix set to .example.com, a request to acme.example.com resolves to acme. With trPath and PathSegmentIndex of 0, /acme/api/orders resolves the same. When the configured source yields nothing, DefaultTenant is used.
Reading it in a handler is just a property:
procedure TForm1.ServerCommandGet(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
begin
AResponseInfo.ResponseNo := 200;
AResponseInfo.ContentType := 'application/json';
AResponseInfo.ContentText := '{"tenant":"' + FServer.Tenant + '"}';
end;
Tenant is valid inside OnBeforeCommand, OnCommandGet and OnCommandOther, and it is thread local, so a busy server serving many tenants at once never mixes them up.
On trJWTClaim there is one thing to be clear about. The claim is read from the token payload without validating the signature, because the tenant is only a routing hint. The signature is still checked by the JWT authentication when you enable it. Do not treat the tenant as proof of identity on its own.
If none of the five modes fits, OnResolveTenant hands you every source at once and lets you decide:
procedure TForm1.TenancyResolveTenant(Sender: TObject;
const aHost, aPath, aHeaderValue, aJWTPayload: string; var aTenant: string);
begin
aTenant := LookupTenantForHost(aHost);
end;
Metrics and health
TsgcHTTPServerStats counts what the server did and can publish it on two endpoints. Both are off until you enable them individually, so nothing becomes reachable just because you dropped the component on the form.
FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Metrics.Enabled := True;
FStats.Endpoints.Health.Enabled := True;
FServer.ServerStats := FStats;
The counters are also readable from code, which is handy for an internal status screen:
lblRequests.Caption := IntToStr(FStats.TotalRequests);
lblErrors.Caption := IntToStr(FStats.Status5xx);
lblLatency.Caption := IntToStr(FStats.LatencyAvgMs) + ' ms';
lblUptime.Caption := IntToStr(FStats.UptimeSeconds) + ' s';
/metrics answers Prometheus text exposition format directly, no exporter in between. Per-endpoint counters are included, with the cardinality capped at 256 distinct paths so a route with an id in it cannot blow up the series count. Everything past the cap is bucketed under 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
If the firewall, rate limiter, circuit breaker or API key manager components are attached, their own metrics are added to the same output:
FStats.RateLimiter := FRateLimiter;
FStats.Firewall := FFirewall;
/health answers a compact JSON document. Its status reads ok, or degraded when a circuit breaker is attached and has open breakers, which makes it directly usable as a load balancer probe.
Finally, OnStats fires with the whole stats object if you would rather push the numbers somewhere yourself:
procedure TForm1.StatsEvent(Sender: TObject;
const aStats: TsgcHTTPServerStats);
begin
MyTelemetry.Send(aStats.TotalRequests, aStats.LatencyAvgMs);
end;
Putting it together
A server with all three attached is about a dozen lines, and each component stays independent of the others:
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;
The next article puts an OpenAPI contract in front of this server, so routing, validation and documentation all come from the spec.
Download the latest build from the sgcWebSockets download page.
