ASP.NET Core 申請・承認機能を作る③ 承認・却下と権限の確認
申請中の内容を、承認者が承認または却下できるようにします。
「承認権限を持っているか」だけでは、実行してよいかを判断できません。対象の組織、申請の状態、申請者本人かどうかも確認する必要があります。
前回の記事で作ったApprovalServiceとApprovalsControllerに処理を追加します。
承認できる条件を確認する
第1回のApprovalPolicy.GetActionsでは、次の条件をすべて満たす場合に承認・却下を許可しています。
| 条件 | 防ぎたい操作 |
|---|---|
| 同じ組織に所属している | 別組織の申請を操作する |
| 現在の所属に承認権限がある | 権限のない人が判断する |
| 申請者本人ではない | 自分で自分の申請を通す |
状態がSubmittedである | 下書きや判断済みの申請を操作する |
管理者という名前のロールを持っていても、今回のルールでは自己承認を許可しません。必要なら例外を設計しますが、画面だけで特別扱いを追加しないようにします。
利用者と対象データを合わせて判断する考え方は、ASP.NET Coreのリソースベース認可でも説明されています。このシリーズでは、条件が小さいため共通のApprovalPolicyにまとめています。
承認と却下のサービスを追加する
承認と却下で、対象の取得やVersionの確認は共通です。異なる部分を引数で受け取り、1つのメソッドにまとめます。
ApprovalService.Decisions.cspublic sealed partial class ApprovalService
{
public Task<ApprovalDetail> ApproveAsync(
Guid org, Guid actor, Guid id, VersionInput input, CancellationToken ct) =>
DecideAsync(org, actor, id, input.ExpectedVersion, true, null, ct);
public Task<ApprovalDetail> RejectAsync(
Guid org, Guid actor, Guid id, RejectApproval input, CancellationToken ct) =>
DecideAsync(org, actor, id, input.ExpectedVersion, false, input.Reason, ct);
private async Task<ApprovalDetail> DecideAsync(
Guid org, Guid actor, Guid id, long expectedVersion,
bool approve, string? reason, CancellationToken ct)
{
await using var session = await store.OpenAsync(org, actor, id, ct);
var member = RequireMember(session);
var item = RequireVisible(session, member);
CheckVersion(item, expectedVersion);
var actions = ApprovalPolicy.GetActions(item, member);
if (approve ? !actions.CanApprove : !actions.CanReject)
throw new WorkflowError(403, "decision_not_allowed");
var normalizedReason = approve ? null : reason?.Trim();
if (!approve && (string.IsNullOrWhiteSpace(normalizedReason) ||
normalizedReason.Length > 500))
throw new WorkflowError(400, "rejection_reason_invalid");
var next = item with
{
Status = approve ? ApprovalStatus.Approved : ApprovalStatus.Rejected,
DecisionReason = normalizedReason,
Version = item.Version + 1,
};
await SaveAsync(session, item, next, actor,
approve ? "approved" : "rejected", normalizedReason, ct);
return ToDetail(next, member);
}
}approveは、コントローラーから任意の真偽値を受け取っているわけではありません。承認用のApproveAsyncと却下用のRejectAsyncが内部で指定しています。公開APIは操作ごとに分かれたままです。
DecideAsyncは次の順番で確認します。
- 現在の所属と申請を、保存層から取得する
- 閲覧できる対象であることを確認する
- 画面で見ていたVersionと一致することを確認する
- 現在の権限と状態で、承認または却下できることを確認する
- 必要なら却下理由を検証する
- 状態と履歴をまとめて保存する
却下理由はサーバーでも必須にする
却下の場合は、前後の空白を取り除いた理由を必須にし、500文字を超えたら拒否します。
画面のボタンを無効にするだけでは、APIを直接呼ばれた場合に空の理由を受け付けてしまいます。そのため、フロントエンドとバックエンドの両方で確認します。
承認の場合は理由をnullにします。前の入力や別の操作の理由が残らないよう、状態と関連する値を同じ更新で決めています。
コントローラーにURLを追加する
次のファイルも、同じApprovalsControllerの一部です。
ApprovalsController.Decisions.csusing Microsoft.AspNetCore.Mvc;
public sealed partial class ApprovalsController
{
[HttpPost("{id:guid}/approve")]
public Task<IActionResult> Approve(Guid org, Guid id, VersionInput input, CancellationToken ct) =>
Run(actor => service.ApproveAsync(org, actor, id, input, ct));
[HttpPost("{id:guid}/reject")]
public Task<IActionResult> Reject(Guid org, Guid id, RejectApproval input, CancellationToken ct) =>
Run(actor => service.RejectAsync(org, actor, id, input, ct));
}既存ファイルに付けたAuthorizeとCSRF検証の属性は、同じクラスのこれらのアクションにも適用されます。
承認の本文には、画面で取得したVersionだけを送ります。
承認する本文{
"expectedVersion": 3
}却下する場合は、理由も送ります。
却下する本文{
"expectedVersion": 3,
"reason": "既存の備品を利用できるため、今回は購入を見送ります。"
}applicantId、organizationId、canApprove、更新後のstatusは本文から受け取りません。操作の権限や結果は、サーバーで確定します。
承認権限を画面で持ち続けない
画面を開いた時点では承認できても、そのあと権限が取り消される場合があります。そのため、APIが返したallowedActions.canApproveを、更新時の許可証として扱わないようにします。
今回の保存層は、更新するトランザクション内で所属と権限を読み直します。さらに権限変更との競合を扱うため、その所属行を共有ロックします。具体的なSQLは第5回で説明します。
このロックは、画面を開いている間ずっと維持するものではありません。ボタンを押してAPIが処理している短い時間だけ取得します。
判断済みの申請をもう一度操作したらどうするか
このシリーズでは、同じ操作を再送した場合も、古いVersionなら409を返します。成功したことにして同じレスポンスを返す方式にはしていません。
最新のVersionであっても、既に承認済みなら操作可否がfalseになるため403です。
| 状況 | 応答 |
|---|---|
| 申請が存在しない、または閲覧できない | 404 |
| 画面が持つVersionが古い | 409 |
| Versionは一致するが、操作権限や状態の条件を満たさない | 403 |
| 却下理由が空、または長すぎる | 400 |
| 承認・却下に成功した | 最新の詳細を200で返す |
理由を区別すると、画面は「入力を直す」「状態を読み直す」「権限を確認する」を案内しやすくなります。エラー形式の扱いはProblem DetailsをReactで扱う方法も参考になります。
承認とメール送信を同じ処理に詰め込まない
承認後に通知メールを送りたくなっても、DBをロックしたままメール送信の完了を待つと、外部サービスの遅延が申請の更新へ影響します。
通知を確実に送りたい場合は、承認と同じトランザクションで「通知する予定」を保存し、別の処理で配信します。詳細はTransactional Outboxで外部処理につなぐ方法で説明しています。
今回のサービスでは、状態と履歴の保存までを担当します。次はReactで状態に応じた画面を作るへ進みます。