Axumのハンドラで任意で渡されるクエリパラメータを受け取る

たとえばAxumのハンドラでクエリパラメータ page を受け付けるときに、

  • page=1が付与されていれば値として1を使う
  • page=aのように値が無効ならデフォルト値を使う
  • pageが付与されていないならデフォルト値を使う
  • page=のように値が空ならデフォルト値を使う

… のすべてをうまくハンドリングしたい。

方法

1つの方法として、たとえばAxumのextractorのドキュメントでも取り上げられているPaginationを任意のクエリパラメータに対応させると次のように書ける。crateとしてserde、serde_withも必要です。

#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Pagination {
    const DEFAULT_PAGE: usize = 1;
    const DEFAULT_PER_PAGE: usize = 10;

    pub fn params(&self) -> (usize, usize) {
        (
            self.page.unwrap_or(Self::DEFAULT_PAGE),
            self.per_page.unwrap_or(Self::DEFAULT_PER_PAGE),
        )
    }
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(Self::DEFAULT_PAGE),
            per_page: Some(Self::DEFAULT_PER_PAGE),
        }
    }
}

pub async fn get_products(query: Option<Query<Pagination>>) {
    let (page, per_page) = query.unwrap_or_default().params();

    // ...
}

これは

  • GET /products
  • GET /products?page=1
  • GET /products?per_page=10
  • GET /products?page
  • GET /products?page=
  • GET /products?page=a

のいずれも対応できる。有効なパラメータでなければ、デフォルト値を使う。

解説

AxumのextractorとSerdeを併用すると、クエリパラメータを引っこ抜いて構造体に格納できる。

#[derive(Deserialize)]
pub struct Pagination {
    pub page: usize,
    pub per_page: usize,
}

pub async fn get_products(query: Query<Pagination>) {
  // let Query(pagination) = query;
  // let page = query.page;
  // ...
}

しかし、このコードだと GET products?page=GET /products? のようにパラメータが送られてこなかったときエラーになる。

Failed to deserialize query string: missing field `page`

まず、GET /products?のようにそもそもパラメータが見つからないならNoneとして扱いたい。これはフィールドをOptionで包めばよい。

#[derive(Debug, Deserialize)]
pub struct Pagination {
    pub page: Option<usize>,
    pub per_page: Option<usize>,
}

さらに、パラメータは送られてきても値が空のときはNone扱いにしたい。これは#[serde(default)]とserde_withのNoneAsEmptyStringを併用することで実現できる。NoneAsEmptyStringを使うとパラメータの値が空(?page=のような形式)をNoneにデシリアライズできるが、その代わりパラメータのキーの存在が必須になる。ここで#[serde(default)]を付与することで、キーが存在しないときはデフォルト値をフィールドにセットするようにしておく。デフォルト値を与えるためにDefaultを実装しておくのがよい。

#[serde_as] // 先頭に付与する必要がある
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(1),
            per_page: Some(10),
        }
    }
}

さらに、パラメータの値が構造体のフィールドの型と合わずデシリアライズでエラーになるとき(?page=abcのような形式)はデフォルトの値を使うとすると、extractor自体をOptionで包んでエラー時にはNoneになるようにしておき、先ほど実装したDefaultを利用することができる。

pub async fn get_products(query: Option<Query<Pagination>>) {
    // paginationはデシリアライズできたものかDefault::default()で得られるもののどちらかになる
    let Query(pagination) = query.unwrap_or_default();
}

これらすべてを取り込むと、最初に書いたようなコードになる。

他の方法

Query extractorは内部(extract::Query::try_from_uri)で文字列をSerdeのデシリアライザに渡しているので、カスタムのデシリアライザを書けばNoneAsEmptyStringは使わなくてよくなりそう。また、たとえばpageper_pageの片方がデシリアライズできなければ、全体をデフォルト値にフォールバックしていたが、それもカスタムのデシリアライザでデフォルト値にするなどの方法も考えられる。

SQLxでテスト実行時にDBのデータを管理する

RDBをデータストアとして使うWebアプリケーション(のバックエンド)の開発で、テストを通じてDBに作成したレコードを自動で削除できると、開発環境のDBが汚れず、また他のテストによって変更されたDBの状態に影響されないので便利。Rustではこれをどうやるのかを調べていた。

SQLxのsqlx::test

SQLxには0.6.1からsqlx::testというattribute macroが追加されている1。これを使うと、テスト実行時に次のような機能を利用できる。

  • テスト実行時にマイグレーションを実行する
  • テスト実行、終了時にテスト用DBを作成、削除する
  • テスト実行時にフィクスチャをDBに投入する

SQLxのリポジトリのexamplesに、簡単なREST APIのテストにおけるsqlx::testのわかりやすい利用例が置いてあるので、使いかたを知りたければこれを見るとよい。

https://github.com/launchbadge/sqlx/tree/main/examples/postgres/axum-social-with-tests

次のような流れで使う。

  • 非同期のテスト関数に#[sqlx::test]を付与する
    • #[sqlx::test]#[tokio::test] のように非同期関数をテストとして実行できる機能もある
  • #[sqlx::test]の引数としてfixturesでテスト実行時にあらかじめDBにレコードを投入するためのクエリを実行する
    • テストファイルと同じディレクトリにfixtures/users.sqlのようなファイルを置く
  • テスト関数は引数としてsqlxが管理するコネクションプールを受け取る
    • このプールを通じてDBに書き込んだレコードはテスト終了時にDBごと削除される
// examples/postgres/axum-social-with-tests/tests/post.rs から引用

#[sqlx::test(fixtures("users"))]
async fn test_create_post(db: PgPool) {
    let mut app = http::app(db);

    // Happy path!
    let mut resp1 = app
        .borrow_mut()
        .oneshot(Request::post("/v1/post").json(json! {
            {
                "auth": {
                    "username": "alice",
                    "password": "rustacean since 2015"
                },
                "content": "This new computer is blazing fast!"
            }
        }))
        .await
        .unwrap();

    assert_eq!(resp1.status(), StatusCode::OK);

    let resp1_json = response_json(&mut resp1).await;

    assert_eq!(resp1_json["username"], "alice");
    assert_eq!(resp1_json["content"], "This new computer is blazing fast!");

    // ...
}

sqlx::testをいつ使うか

sqlx::testはおおむね便利そうだが、テスト関数ごとにDBを作成、フィクスチャ投入、削除を実行する2。テストが増えると速度は落ちそう。

その点を踏まえたWebアプリのテストでの使いどころは、やはり先に述べたようなエンドポイントをテストする場合といえそう。このようなテストは、テストサイズの観点における中テスト (Medium test)として、他のプロセスで動くRDBも利用しつつ、1つのエンドポイントが期待通り動くかを確認するのに役立つ。一方で、もう少し細かいモジュールのテストは、オンメモリだけで完結したり、外部依存があればモックを使って実行するような小テスト (Small test)としてテストすることが多い。エンドポイントに対する主要なテストだけを中テストに任せれば、DB自体の作成と削除が走る回数を相対的に少なく抑えることはできそうだった。

sqlx::testを使うアプリの設計

テストのしやすい設計という観点では、[sqlx::test]を付与した関数に渡るコネクションプールをアプリの実体(axum::Routerとか)に注入できるようにする必要がある。これもAxumなどを使ってアプリを作るときは、アプリに具体的なコネクションプールやリポジトリを注入して、axum::extract::Stateのようなハンドラから使える状態に持たせておけば問題ないはず。イメージとしては次のようなコード。

// アプリの状態
#[derive(Clone)]
pub struct AppState {
    // ハンドラ間でなんらかのリポジトリを共有する
    pub repository: Arc<Repository>,
}

// アプリ本体
pub fn app(repository: Repository) -> Router {
    let state = AppState {
        repository: Arc::new(repository),
    };

    // axum::Router
    Router::new()
        .route("/products/:id", get(get_product))
        .with_state(state)
}

// ハンドラ
pub async fn get_product(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> impl IntoResponse {
    // 状態からリポジトリを取得してアプリ固有のロジックを実行する
    let products_app = ProductApp::new(state.repository.clone());
    
    // ...
}

// 引数にsqlx::PgPoolを受けるテスト関数
#[sqlx::test(fixtures("products"))]
async fn get_product(pool: PgPool) {
    // コネクションプールをもとにリポジトリを初期化できるようにしておいて、
    // SQLxのテスト用コネクションプールでリポジトリを初期化する
    let repository = Repository::new(pool);
    let app = app(repository);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/products/1")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    // ...
}

  1. migrate featureを有効にする必要がある
  2. たぶん実装は https://github.com/launchbadge/sqlx/blob/v0.7.1/sqlx-core/src/testing/mod.rs#L201-L230 のあたり

『セキュア・バイ・デザイン 安全なソフトウェア設計』を読んだ

ドメイン駆動設計(DDD)の方法論をベースに、ドメインに対する深い理解を獲得し、その理解を設計に反映させてコードを書くことで、セキュアなソフトウェアの開発にも寄与するという主張を中心に据えた本。

ここで、この本では「設計」という言葉を「ソフトウェア開発のプロセス全体における能動的な意思決定」という意味で使っている。

この本を読むことで、セキュアコーディングという側面も考慮したDDDの活用法、実際に新規/既存のアプリケーションの開発にそれらのテクニックを導入して設計を改善する方法を学ぶことができる。

設計を中心に据える理由

開発において、コーディングを通じたモジュールやAPIの設計などに力を入れることが多い一方で、セキュリティについては後回しになりがちだ。そこで、この本で扱う方法論を取り入れることで、設計を通じてセキュリティも自然に向上できるという効果が得ることができる。結果として、セキュリティを単なる機能と捉え、バックログで後回しにされるというありがちな問題の解消に役立つ。

とはいえ、この本で紹介される手法は、あくまでもセキュリティにおける多層防御に役立つ方法の1つであり、システムに対してさまざまな側面からセキュリティ対策を施す必要があることには変わりない。

紹介されている方法

主に次のようなトピックが紹介されている。

  • セキュリティのCIA-T(機密性、完全性、可用性、追跡可能性)について
  • DDDの主要なコンセプト
  • 不変オブジェクト、契約による設計、妥当性検証などのテクニック
  • ドメイン・プリミティブやエンティティの導入
  • テスト自動化や機能フラグなどデリバリー・パイプラインでの考慮事項
  • 適切なエラーハンドリング
  • アプリケーション運用上のテクニック

興味深かったポイントをいくつか紹介する。詳しくは本を参照のこと。

ドメインに対する理解の深さ

浅いモデリングと深いモデリングという2つの観点でドメインモデリングを比較している。

浅いモデリングは、たとえば短絡的に商品の数量にint型を使うような、情報の意味が明示化されていないモデリングのことを指す。一方で、深いモデリングは、該当のコンテキストにおいてその数量自体にもルールが存在する(たとえば注文できる数量の上限下限が存在する、など)ことを理解し、そのルールを含めてモデリングすることを指す。

もちろん深いモデリングが望ましいとされていて、そのようなドメインモデルを作るにはドメインエキスパートとの協働が必要…というのはDDDの文脈でよく議論されているとおり。

設計のテクニック

ドメインに対する理解をもとに、最も基本的な構成要素として、特定のコンテキストで利用できるドメイン・プリミティブ1を作り出す。これは値オブジェクトをベースにしたもので、つねに不変条件を満たし、他のコンテキストの情報などがなくてもそれ自体で利用できる(完結した概念; conceptual whole)ドメインモデルである。

たとえば、「注文」のコンテキストにおいて、有効な数が1〜200個の「商品の数量」というドメインモデルはドメイン・プリミティブとして実現できる。

// Rustでのコード例

pub struct ProductQuantity {
    value: u8,
}

impl ProductQuantity {
    pub fn build(value: u8) -> Result<Self, &'static str> {
        if value == 0 || value > 200 {
            return Err("value must be between 1 and 200");
        }

        Ok(Self { value })
    }

    pub fn value(&self) -> u8 {
        self.value
    }

    pub fn add(&self, addend: u8) -> Result<Self, &'static str> {
        Self::build(self.value + addend)
    }
}

深いモデリングで得られたモデルの一部をドメイン・プリミティブにすることで、設計とセキュリティの両方でよい効果がある。

上記コードだと、ある関数に商品の数量を引数として渡すとき、このドメイン・プリミティブを使えば表現が明示的になるし、このモデルを通すことでバリデーションが実行されることを保証できるので、他の箇所にバリデーションロジックが分散するのを避けられる。また、外部から入力された整数値をそのまま商品の数量とみなさず、いったん不変条件を持つドメイン・プリミティブを通すことになるので、負の数を渡してカートの金額を減らそうとするかもしれない攻撃者にシステムの完全性を侵害される可能性を減らせる。

ドメイン・プリミティブは他のドメイン・プリミティブを持つこともある。エンティティを含め必ずドメイン・プリミティブを使うことで、データが正しい状態にあることを保証できる。

他のテクニックとしては、機密情報へのRead-Onceオブジェクトの利用、完全性を保証するエンティティの生成方法、複雑化したエンティティの状態管理などについて紹介されている。どれも読みやすく変更しやすいコードとセキュアなコードの両方に役立つものである。

安全なエラーハンドリング

エラーハンドリングについても、例外をビジネス例外と技術的例外に分類したのち、例外のデータに起因する情報漏洩に対する対策や、そもそも例外を使わないエラーハンドリング(ActiveModel::ErrorsやResult型などを想像するとわかりやすい)について紹介されている。

以前このブログでも書いたことがあるが、本当に例外的なエラーはバグや技術的に復旧できない問題のはずなので、できるだけ例外を使わずにエラーハンドリングすべしという主張は納得感がある。

blog.kymmt.com

それがセキュリティ向上にもつながるなら尚更だ。

安全なデリバリーや運用

ドメインモデルのコーディング以外の話題として、デリバリーや運用に関するテクニックも紹介されている。例えば、最近はデリバリーにおいて機能フラグを使い、未完成の機能も含めてトランクベース開発するという方法が一般的になっている。しかし、機能フラグが増えるとフラグ間の関係が煩雑になりがちで、かつ設定にミスがあり意図せず機能を公開してしまうと事故につながりかねない。そこで、テストで機能フラグやフラグを利用するロジックが適切に動いているか検証することが推奨されている。

また、システム管理サービス(要は社内管理画面)の重要性も説いている。運用で直接SQLを発行したりSSHでサーバに入るのではなく、顧客に提供しているサービス本体のアプリとは別のサービスとしてシステム管理サービスを運用するのがよいとされている。また、外から入ってきた文字列や機微情報を検証なくログに出力すると攻撃につながるという、いかにもやってしまいそうなケースについても、対象ドメインにおいて検証すべき外部入力や秘匿情報がなんなのかモデリングを通じて理解し、ドメイン・プリミティブやロガーを適切に使って防ぐべきだとしている。

所感

実事例やレガシーアプリケーションへの導入方法の章でも説明されている通り、まず取り組むべきは対象ドメインを詳しく理解するということだった。DDDではとかくコーディングに関するテクニックが着目されがちだが、ドメインの理解やコンテキストの分析が重要ということだろうと思う。ドメインで発生するイベントやリソースについて有識者も交えてしっかり開発者が理解し、そのあとにそれらを抽象化したドメインモデルをドメイン・プリミティブやエンティティなどコードとして落とし込むことが、セキュアなソフトウェア開発に必要な1つの要素なのだろうと思う。


  1. 書籍での表記どおりに中黒を入れている