Skip to content

Core の共有ストア ​

第1版作成 最終更新 (日本時間)

Core.Data は、ツールの管理画面や送信処理が共通で必要とする DB の保管先(ストア) を、Core_ の共有表(マイグレーションと共有表)とセットで提供します。Dapper で実装され、SQLite・SQL Server・PostgreSQL・MySQL で同じインターフェイスで使えます。ツールを動かす人にとっては「DB のどの表に何が入るか」、同じ基盤でツールを作る人にとっては「どのストアに何を任せられるか」の説明です。

プレビュー版・未確認の範囲

共有ストアは 0.1.0-preview.6 で追加され、プレビュー版の間は API が変わり得ます(概要)。このページはリリースタグのソースの読み取りに基づきます。4 つの RDBMS の実機で動かして確かめた内容ではありません。 実際に各ツールが使うかどうかは、ツールごとの変更履歴で確認してください。

全体像 ​

ストアインターフェイス表導入版
管理者・復旧コードIAdminUserStore / AdminUserStoreCore_AdminUsers・Core_AdminSetup・Core_AdminRecoveryCodespreview.6
サーバ側セッションIAdminSessionStore / AdminSessionStoreCore_AdminSessionspreview.6
管理者の招待IAdminInvitationStore / AdminInvitationStoreCore_AdminInvitationspreview.6
監査ログIAuditLogStore / AuditLogStoreCore_AuditLogspreview.6
メール送信箱IMailOutbox / MailOutboxCore_MailOutboxpreview.6
アプリ設定IAppSettingStore / AppSettingStoreCore_AppSettingspreview.6
レート制限IRateLimitStore / RateLimitStoreCore_RateLimitspreview.6

Core は依存の注入(DI)への登録メソッドを持ちません。各ストアは、IDbConnectionFactory(DB の接続)を渡して作ります。現在時刻が要るストアは TimeProvider も受け取ります(試験で時刻を固定するため)。

ストアコンストラクタの引数
RateLimitStoreIDbConnectionFactory
AuditLogStoreIDbConnectionFactory
AdminInvitationStore・AppSettingStore・MailOutbox・AdminSessionStoreIDbConnectionFactory、TimeProvider
AdminUserStoreIDbConnectionFactory、ISecretCipher、TimeProvider、アプリ ID の文字列
csharp
// connections は DbConnectionFactory(DB の接続のページを参照)
var auditLog = new AuditLogStore(connections);
var outbox = new MailOutbox(connections, TimeProvider.System);

上のコードはソースの署名から組み立てた例で、実行して確かめていません。

共通の性質 ​

ストアに共通する設計は、ソースのコメントによると次のとおりです。

  • 競合しうる書き込みは、条件を含めた 1 文の UPDATE / INSERT の件数で判断します。 読んでから書くと、同時に来た 2 つが両方通るためです(招待の使い切り・復旧コードの使い切り・TOTP の時間枠・メールの確保など)。
  • 値は必ずパラメータで渡します。 SQL の文は SqlDialect.Format で RDBMS ごとの引用符に直します。
  • 時刻は UTC で、DbTime を通して入出力します(DB の接続・暗号化・排他)。
  • 秘密の現物を DB に置きません。 パスワードはハッシュ、TOTP の共有鍵・設定の秘密・メールの中身は暗号文、招待のトークンとセッション ID は SHA-256 だけを置きます。

管理者(IAdminUserStore) ​

ツールの管理画面にログインする管理者のアカウントです。

  • 最初の管理者: CreateFirstAsync は、管理者が 1 人も居ないときだけ成功し、居れば null を返します。Core_AdminSetup の主キー(1 行だけ)の一意性で排他するため、同時に 2 人が最初の管理者になることはありません。役割は Administrator になります。
  • ログイン ID は大文字小文字を区別せず一意です(小文字にした LoginIdKey の一意索引)。
  • 役割は名前(文字列)で持ちます。Core が意味を知っているのは Administrator(AdminRoleNames.Administrator)だけで、ほかの役割はアプリが決めます。
  • 最後の有効な Administrator は、無効化も降格もできません(SetDisabledAsync・SetRoleAsync が AdminChangeResult.LastAdministrator を返す)。締め出しを防ぐためです。「有効な Administrator」は、無効でなく、一度でもログインした人です。招待しただけの人は、誰も知らないパスワードしか持たないので数えません。
  • ログイン失敗: RecordFailureAsync が失敗を数え、指定した回数目で指定した時間だけ締め出します。締め出しは DB に持ちます(プロセス内で数えると、インスタンスの数だけ試せてしまうため)。成功すると RecordSuccessAsync が回数を戻し、最終ログインを残します。
  • TOTP(2 要素): 共有鍵は ISecretCipher で暗号化して保管し、アプリ ID と利用者 ID に束縛します(別のアプリの暗号文は復号できません)。ISecretCipher の実装は Core には無く、鍵の管理はアプリの責務です。EnableTotpAsync は共有鍵を確定し、前の復旧コードを消して新しい復旧コード(ハッシュ)に置き換えます。TryUseTotpStepAsync は、前回より新しい時間枠のときだけ使用済みにして true を返します。
  • 復旧コード: ハッシュだけを持ち、TryUseRecoveryCodeAsync で使い切りにします。ListUnusedRecoveryCodeHashesAsync preview.8 以降 は、未使用のハッシュの一覧を返します。塩つきのハッシュは入力から同じ値を作れないので、呼び出し側が 1 件ずつ照合し、一致したハッシュで TryUseRecoveryCodeAsync を呼びます。
  • 表示言語: SetLanguageAsync preview.8 以降 で変えます。16 文字を超える値と、空白だけの値は ArgumentException です。空文字は「選んでいない」(ブラウザの言語に従う)を表し、空へ戻せるのは preview.9 以降です preview.9 以降。preview.8 では、空文字も例外になります。存在しない ID は NotFound を返します。

AdminUser に、パスワードのハッシュと TOTP の共有鍵は含まれません。ハッシュはログイン時に FindForLoginAsync が別の値として返します。

サーバ側セッション(IAdminSessionStore) ​

  • Cookie に載せるのは推測できない 256 bit の ID だけです。DB には ID の SHA-256 だけを置くので、DB が漏れても有効な Cookie は作れません。
  • CreateAsync は、利用者・段階(Pending または Session、アプリは別の段階を足せる)・有効期間を指定してセッションを作り、Cookie に載せる ID を返します。Pending は「パスワードは通ったが、2 要素がまだ」の段階で、何も操作させない短い寿命のものです。
  • 端末の一覧(ListForUserAsync)に出すのは、ID から作った識別子(HandleOf)です。Cookie の現物は作れません。DeleteByHandleAsync は、持ち主の組で消すので、他人の端末の識別子を渡されても消えません。
  • DeleteForUserAsync は、利用者のセッションをすべて消します。exceptSessionId を渡せば、その 1 つだけ残せます(パスワード変更で、いまの端末以外を失効させる用途)。
  • SAML・Pleasanter のログインで入ったセッションの鍵(単一ログアウト・再検証に必要な値)も持てます。DeleteBySamlKeysAsync は、IdP からの単一ログアウトで NameID(とセッション索引)に一致するセッションを捨てます。Cookie が届かない経路のため、Cookie ではなく鍵で探します。
  • 期限切れは DeleteExpiredAsync で消します。定期処理から呼ぶ前提です。

管理者の招待(IAdminInvitationStore) ​

  • トークンの現物は持たず、SHA-256(16 進 64 文字)だけを置きます。トークンで引くため、TokenHash は一意です。
  • ReplaceAsync は、その人の未使用の招待を先に消してから新しい招待を置きます(出し直すと前の招待は使えなくなります)。使用済みの行は、使われた事実として残します。
  • TryConsumeAsync は、未使用の行だけを更新し、更新できた件数で成否を判断します。同時に来た 2 つの使用が両方通ることはありません。
  • ListPendingAdminUserIdsAsync は、招待中(未使用で期限内)の管理者の ID を返します。RevokeUnusedAsync は、未使用の招待を取り消します。管理者を止めたときに、その相手宛ての招待も取り消す用途です。

監査ログ(IAuditLogStore) ​

管理操作の記録です。追記だけで、ほかの表へ外部キーを張らないので、操作した人が消えても記録は残ります。

  • WriteAsync は 1 件を書きます。失敗した操作も残します(試みられたこと自体が分からなくなるため)。居ないログイン ID での試みも残るよう、実行者のログイン ID は利用者の行とは別に持ちます。
  • 資格情報・回答本文を入れない設計です。要求本文は渡さず、「何を・誰が・どの対象か」だけを渡します。DetailJson にも資格情報を入れないでください。
  • 文字列は AuditText.Safe を通り、制御文字(改行を含む)を落として列の桁まで切り詰めます。ログの偽装(行の差し込み)と、桁溢れで記録だけ落ちることを防ぎます。DetailJson だけは JSON をそのまま持ちます。
  • ListAsync は新しい順に読みます。AuditLogQuery で、期間(From / To)・実行者(ActorKey)・操作の名前(Action は完全一致)・ActionContains・ActorLoginContains・失敗のみ(FailedOnly)・件数(Limit、既定 100、最大 500)・読み飛ばし(Offset)で絞れます。
    • ページ送りは Offset で行い、時刻では区切りません。 DB へ渡す時刻は秒未満を切り捨てるため、同じ秒の記録が複数あり、時刻で区切ると取りこぼします。並びは OccurredAt の降順、同じ時刻は AuditLogId の降順です。
    • ActorLoginContains は preview.7 で追加されました preview.7 以降。ActionContains は、preview.6 では LIKE をそのまま使っていたため、大文字小文字の扱いが RDBMS と照合順序に依存していました。preview.7 以降は、両辺を小文字にして比べるので、ActorLoginContains と同じく大文字小文字を区別しない部分一致です。
    • LIKE に渡す値は %・_・[ を逃がすので、利用者が書いた記号が「何でも」の意味にはなりません。
  • DeleteOlderThanAsync は、指定した時刻より古い記録を消します。消さないと増え続ける(1 操作 1 行)ので、定期処理から呼ぶ前提です。

メール送信箱(IMailOutbox) ​

メールをすぐ送らず、いったん DB に積んで、送信ワーカーが送る構造(アウトボックス)のための保管先です。送る側(Core.Mail の IMailTransport)は メール送信 を参照してください。

  • 宛先・件名・本文は 1 つにまとめて暗号化した文字列(PayloadProtected)として置きます。 列を分けると、暗号化の掛け忘れが列ごとに起き得るためです。暗号化・復号は Core.Mail の IMailPayloadProtector(実装はアプリ側)の役割です。
  • EnqueueAsync は、同じ識別子がすでにあれば何もせず false を返します(二重に送らない側へ倒す)。Kind(種類)の値の意味はアプリが決めます。SubjectId は、そのメールが何に紐づくかをアプリが任意に持つ値です。
  • ClaimAsync は、送れる 1 通を 1 文の UPDATE で原子的に確保します。複数の送信ワーカーが同じ 1 通を取ることはありません。確保には期限(LockedUntil)が付き、確保したまま落ちても、ReleaseExpiredLocksAsync が期限切れの行を送信待ちへ戻すので、メールは失われません。
  • 状態は Pending(送信待ち。再送待ちを含む)・Sending(送信中)・DeadLetter(再送しても送れない。人が見る必要がある)で、DB には整数(0・1・2)で入ります。値を入れ替えないでください。
  • 結果は CompleteAsync(送れたので行ごと消す。宛先と本文を持ち続けない)、RescheduleAsync(再送待ちへ戻し、回数を 1 増やし、次の試行時刻とエラーを残す)、DeadLetterAsync(分離する)で書き戻します。
  • GetStatusAsync は、送信待ちの件数・最も古い送信待ちの作成時刻・デッドレターの件数を返します。管理画面や監視に出す値です。

アプリ設定(IAppSettingStore) ​

管理画面から編集する設定の保管先です。

  • 優先順位は、外部設定(環境変数・Parameters。画面からは変えられない)が最上位、次にこの表、最後に既定値です(Core_AppSettings のマイグレーションのコメント)。この解決はアプリの側で行い、ストアは値の保管だけを担います。
  • 秘密(IsSecret)は暗号文で置き、画面へは返さない前提です。ストアは受け取った値をそのまま置くので、暗号化は呼び出し側で行います。起動に要る値(DB の接続・秘密の鍵など)はここへ置きません。
  • アプリごとに別の DB なので、設定名(SettingKey)はそのアプリの名前空間(<ID>_*)で持ちます。
  • SaveAsync は置き換えです(同じ設定名があれば消して入れ直す。1 つのトランザクション)。DeleteAsync は消します(既定へ戻す)。

レート制限(IRateLimitStore) ​

複数台で共有する、固定窓のレート制限の数えです。

  • HitAsync(key, window) は、その窓での数えを 1 増やし、増やした後の数を返します。窓が変わっていたら 1 に戻します。窓は、呼び出し側が時間を窓の長さで割った整数で渡します。 上限の判定も呼び出し側で行います。
  • 読んでから足さず、1 文の UPDATE で数えるので、複数台が同時に数えても数えこぼしません。行が無いときだけ INSERT し、同時に別の台が先に作っていたら UPDATE をやり直します。3 回やり直しても決まらないときは InvalidOperationException です(DB の不調などの異常として、呼び出し側が別の数え方へ回す想定)。
  • PurgeAsync(beforeWindow) は、古い窓の行を消し、消した件数を返します。定期処理から呼びます。

ツールを作るときの注意 ​

  • Core に置いた表を、アプリの SQL から直接書き換えないでください。 守っている性質(使い切り・原子性・秘密の扱い)が崩れます。
  • ストアが暗号化の実装を持たない箇所(ISecretCipher、IMailPayloadProtector、設定の秘密値)は、アプリが鍵の管理も含めて実装します。
  • 定期処理が要るストアがあります(セッションの期限切れ・監査ログの古い記録・レート制限の古い窓・メール送信箱の期限切れの確保)。呼び出しはアプリ側で用意します。
  • プレビュー版の間は、ストアのインターフェイスにメソッドが足されることがあります(preview.8 で IAdminUserStore に 2 つ足されました)。インターフェイスを自前で実装している場合は、版を上げるとビルドが通らなくなる可能性があります。

関連ページ ​

変更履歴

第1版共通ライブラリ Core のマニュアルを追加する (#35)