REST サーバー: ユーザー、マルチテナンシー、メトリクス | eSeGeCe ブログ

REST サーバー: ユーザー、マルチテナンシー、メトリクス

· コンポーネント
sgcWebSockets REST server users, tenancy and metrics

最初の記事では TsgcHTTPRESTServer とそのリクエスト処理を紹介しました。今回は、動作するエンドポイントを実際に顧客の前に出せるものへと変える 3 つのコンパニオンコンポーネント、すなわちユーザーストア、マルチテナンシー、そして運用チームが初日に求めてくるメトリクスを扱います。

3 つはいずれも独立したコンポーネントです。必要なものだけを作成してサーバーに割り当てます。割り当てなかったものは、リクエストごとにポインタを 1 回チェックするだけのコストしかかかりません。

TStringList の代わりにユーザーストアを

TsgcHTTPServer_Users は、ソルト付きで反復処理されたパスワードハッシュとともにアカウントを保持します。重要なのは、必要な配線がごくわずかだという点です。サーバーの認証オプションに割り当てるだけで、HTTP Basic のゲートが自動的にそのストアに対して認証情報を照合します。

uses
  sgcHTTP_REST_Server, sgcHTTP_REST_Server_Users;

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

これだけです。Basic 認証を動作させるために OnAuthentication ハンドラーを書く必要はありません。ゲートが検索とハッシュの比較を行います。

アカウントは AddUser で追加します。生成されたユーザー Id を返し、ユーザー名が空か、すでに使われている場合は空の文字列を返します。

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

それ以外の API は想像どおりのもので、すべての検索はスレッドセーフです。

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

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

ロール

ロールはアカウント上のカンマ区切りのリストで、読み取りと判定のためのヘルパーが用意されています。ハンドラー内のロールチェックは 1 回の呼び出しで済みます。

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;

ハッシュ化

パスワードはデフォルトで SHA-512 を 10000 回反復してハッシュ化され、アカウントごとに独自のランダムなソルトを持ちます。このアカウントごとのソルトがあるため、同一のパスワードでも異なるハッシュになります。したがって、ここに手を加える必要はほとんどありません。

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

Salt プロパティはこれとは別物で、注意して読む価値があります。これは、アカウントごとのソルトに加えてすべてのハッシュに混ぜ込まれる任意のペッパーであり、意図的にデフォルトでは空になっています。生成時にその場で作った値は次回の実行では異なってしまい、正しいパスワードがすべて拒否されるうえ、その理由を説明するエラーも出ません。設定するのは、ユーザーストア自体とは別の場所、たとえば環境変数やキーボールトから値を取得できる場合だけにしてください。保護対象のハッシュのすぐ横に置かれたペッパーには何の意味もありません。この値を変更すると、保存済みのパスワードはすべて無効になります。

永続化

ストアはデフォルトではメモリ上にあります。ファイルを指定すれば、再起動しても内容が保持されます。

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

AutoSaveSeconds を設定すると、ストアは定期的に自身をフラッシュします。設定しない場合は、都合のよいタイミングで SaveUsers または SaveToFile を呼び出してください。すでに自分のデータベース上にあるストアを使う場合は、StorageTypeustCustom にしてイベントで応答します。この場合は OnValidateCredentials が正となります。

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

管理画面を作る場合に重要な点が 1 つあります。GetUserByIndex は、レコードを返す前に PasswordHashSalt を空にします。そのため、一覧を返すルートが誤って認証情報を漏らすことはありません。FindUser はこれを行いません。認証ゲート自身が使用する検索であるため、認証情報のフィールドも含めて、保存されているままのレコードを返します。必要なフィールドだけをそこから読み取り、レコード全体をレスポンスボディにシリアライズすることは絶対に避けてください。

マルチテナンシー

TsgcHTTPServer_Tenancy は、リクエストごとに 1 つの問いに答えます。これはどの顧客のためのものか、という問いです。ハンドラーが実行される前にテナント文字列を解決し、サーバーは読み取り専用の Tenant プロパティでそれを公開します。

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

解決モードは 5 つあります。

解決モード取得元設定に使うプロパティ
trNone無効。Tenant は常に空
trHostホスト名HostSuffix
trPathリクエストパスのセグメントPathSegmentIndex
trHeaderリクエストヘッダーHeaderName
trJWTClaimベアラートークンのクレームClaimName

trHost を使い HostSuffix.example.com を設定した場合、acme.example.com へのリクエストは acme に解決されます。trPathPathSegmentIndex を 0 にすると、/acme/api/orders も同じ結果になります。設定した取得元から何も得られない場合は、DefaultTenant が使われます。

ハンドラーでの読み取りは、単なるプロパティアクセスです。

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

TenantOnBeforeCommandOnCommandGetOnCommandOther の中で有効で、スレッドローカルです。そのため、多数のテナントを同時に処理する高負荷のサーバーでも取り違えることはありません。

trJWTClaim については、はっきりさせておくべき点が 1 つあります。クレームはトークンのペイロードから、署名を検証せずに読み取られます。テナントはルーティングのヒントにすぎないからです。署名そのものは、JWT 認証を有効にすればそちらで検証されます。テナントだけを本人確認の証拠として扱わないでください。

5 つのモードのいずれも合わない場合は、OnResolveTenant がすべての取得元を一度に渡してくれるので、自分で判断できます。

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

メトリクスとヘルス

TsgcHTTPServerStats はサーバーが行った処理を集計し、2 つのエンドポイントで公開できます。どちらも個別に有効にするまでは無効なので、フォームにコンポーネントを置いただけで何かにアクセスできるようになることはありません。

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

カウンターはコードからも読み取れるため、社内向けのステータス画面に便利です。

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

/metrics は Prometheus のテキスト公開形式を直接返すため、間にエクスポーターは不要です。エンドポイントごとのカウンターも含まれますが、カーディナリティは 256 個の異なるパスに制限されているため、Id を含むルートが系列数を膨張させることはありません。上限を超えた分はすべて 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

ファイアウォール、レートリミッター、サーキットブレーカー、API キーマネージャーの各コンポーネントが割り当てられている場合は、それぞれのメトリクスも同じ出力に追加されます。

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

/health はコンパクトな JSON ドキュメントを返します。statusok になり、サーキットブレーカーが割り当てられていて開いているブレーカーがある場合は degraded になります。そのため、ロードバランサーのプローブとしてそのまま使えます。

最後に、数値を自分でどこかへ送りたい場合は、OnStats が統計オブジェクト全体とともに発生します。

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

組み合わせる

3 つすべてを割り当てたサーバーでも十数行で済み、各コンポーネントは互いに独立したままです。

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;

次の記事では、このサーバーの前面に OpenAPI コントラクトを置き、ルーティングも検証もドキュメントもすべて仕様から得られるようにします。

最新のビルドは sgcWebSockets のダウンロードページから入手してください。