zukucode
主にWEB関連の情報を技術メモとして発信しています。

ASP.NET Core 申請・承認機能を作る③ 承認・却下と権限の確認

申請中の内容を、承認者が承認または却下できるようにします。

「承認権限を持っているか」だけでは、実行してよいかを判断できません。対象の組織、申請の状態、申請者本人かどうかも確認する必要があります。

  1. 状態とDBを設計する
  2. 下書き保存と申請API
  3. 承認・却下と権限の確認(この記事)
  4. 状態に応じた画面を作る
  5. 同時操作と履歴を扱う

前回の記事で作ったApprovalServiceとApprovalsControllerに処理を追加します。

承認できる条件を確認する

第1回のApprovalPolicy.GetActionsでは、次の条件をすべて満たす場合に承認・却下を許可しています。

条件防ぎたい操作
同じ組織に所属している別組織の申請を操作する
現在の所属に承認権限がある権限のない人が判断する
申請者本人ではない自分で自分の申請を通す
状態がSubmittedである下書きや判断済みの申請を操作する

管理者という名前のロールを持っていても、今回のルールでは自己承認を許可しません。必要なら例外を設計しますが、画面だけで特別扱いを追加しないようにします。

利用者と対象データを合わせて判断する考え方は、ASP.NET Coreのリソースベース認可でも説明されています。このシリーズでは、条件が小さいため共通のApprovalPolicyにまとめています。

承認と却下のサービスを追加する

承認と却下で、対象の取得やVersionの確認は共通です。異なる部分を引数で受け取り、1つのメソッドにまとめます。

ApprovalService.Decisions.cs
public 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は次の順番で確認します。

  1. 現在の所属と申請を、保存層から取得する
  2. 閲覧できる対象であることを確認する
  3. 画面で見ていたVersionと一致することを確認する
  4. 現在の権限と状態で、承認または却下できることを確認する
  5. 必要なら却下理由を検証する
  6. 状態と履歴をまとめて保存する

却下理由はサーバーでも必須にする

却下の場合は、前後の空白を取り除いた理由を必須にし、500文字を超えたら拒否します。

画面のボタンを無効にするだけでは、APIを直接呼ばれた場合に空の理由を受け付けてしまいます。そのため、フロントエンドとバックエンドの両方で確認します。

承認の場合は理由をnullにします。前の入力や別の操作の理由が残らないよう、状態と関連する値を同じ更新で決めています。

コントローラーにURLを追加する

次のファイルも、同じApprovalsControllerの一部です。

ApprovalsController.Decisions.cs
using 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で状態に応じた画面を作るへ進みます。


関連記事