Core の共有ストア
Core.Data は、ツールの管理画面や送信処理が共通で必要とする DB の保管先(ストア) を、Core_ の共有表(マイグレーションと共有表)とセットで提供します。Dapper で実装され、SQLite・SQL Server・PostgreSQL・MySQL で同じインターフェイスで使えます。ツールを動かす人にとっては「DB のどの表に何が入るか」、同じ基盤でツールを作る人にとっては「どのストアに何を任せられるか」の説明です。
プレビュー版・未確認の範囲
共有ストアは 0.1.0-preview.6 で追加され、プレビュー版の間は API が変わり得ます(概要)。このページはリリースタグのソースの読み取りに基づきます。4 つの RDBMS の実機で動かして確かめた内容ではありません。 実際に各ツールが使うかどうかは、ツールごとの変更履歴で確認してください。
全体像
| ストア | インターフェイス | 表 | 導入版 |
|---|---|---|---|
| 管理者・復旧コード | IAdminUserStore / AdminUserStore | Core_AdminUsers・Core_AdminSetup・Core_AdminRecoveryCodes | preview.6 |
| サーバ側セッション | IAdminSessionStore / AdminSessionStore | Core_AdminSessions | preview.6 |
| 管理者の招待 | IAdminInvitationStore / AdminInvitationStore | Core_AdminInvitations | preview.6 |
| 監査ログ | IAuditLogStore / AuditLogStore | Core_AuditLogs | preview.6 |
| メール送信箱 | IMailOutbox / MailOutbox | Core_MailOutbox | preview.6 |
| アプリ設定 | IAppSettingStore / AppSettingStore | Core_AppSettings | preview.6 |
| レート制限 | IRateLimitStore / RateLimitStore | Core_RateLimits | preview.6 |
Core は依存の注入(DI)への登録メソッドを持ちません。各ストアは、IDbConnectionFactory(DB の接続)を渡して作ります。現在時刻が要るストアは TimeProvider も受け取ります(試験で時刻を固定するため)。
| ストア | コンストラクタの引数 |
|---|---|
RateLimitStore | IDbConnectionFactory |
AuditLogStore | IDbConnectionFactory |
AdminInvitationStore・AppSettingStore・MailOutbox・AdminSessionStore | IDbConnectionFactory、TimeProvider |
AdminUserStore | IDbConnectionFactory、ISecretCipher、TimeProvider、アプリ ID の文字列 |
// 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で使い切りにします。ListUnusedRecoveryCodeHashesAsyncpreview.8 以降 は、未使用のハッシュの一覧を返します。塩つきのハッシュは入力から同じ値を作れないので、呼び出し側が 1 件ずつ照合し、一致したハッシュでTryUseRecoveryCodeAsyncを呼びます。 - 表示言語:
SetLanguageAsyncpreview.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 つ足されました)。インターフェイスを自前で実装している場合は、版を上げるとビルドが通らなくなる可能性があります。