Cedar(Amazon Verified Permissionsのポリシーのための言語)のチュートリアルを読んだ

Amazon Verified PermissionsとはAWSが提供するアクセス管理サービス(認可エンジンとも)です。 ざっくり言うと、アプリケーションでの操作を許可するか拒否するかを評価してくれます。

Amazon Verified Permissionsについては、以前にカンファレンスの動画を見てその内容をまとめたり、リンクをまとめたりしました。気になる方は最初にそちらを読んでください。

s1r-j.hatenablog.com

さて、Amazon Verified Permissionsは、誰かが何かに対してどうするときにそれを許可するまたは拒否するということが書かれたポリシーをもとに評価をおこないます。このポリシーはCedarという言語で書かれています。

今回はCedarのチュートリアルをやったので、一部日本語訳をしながら備忘録としてまとめておきます。

チュートリアルの内容

1. ポリシー構造

Cedarでは、権限(permission)はポリシーステートメント(policy statement)の集まりとして表現されます 各ポリシーステートメントは、定義されたコンテキストでユーザ(またはプリンシパル)がリソースに対しておこなう動作を許可するまたは禁止するルールです。この形式はPARCモデルと呼ばれます。

AWS再入門ブログリレー AWS Identity and Access Management (IAM)編 | DevelopersIO

ポリシー言語にはEffectとPrincipal、Action、Resources、Conditionで構成されており、それぞれの頭文字をとってPARCモデルといったりもしています。

全てのポリシーステートメントは、エフェクト(effect)とスコープ(scope)を含まなければなりません。

  • エフェクトは、permit(許可)かforbid(禁止)ポリシーを指定します
  • スコープは、エフェクトが適用されるプリンシパル(principal)、アクション(action)、リソース(resource)を指定します
  • オプションで、ステートメントwhenまたはunless句を使うことで1つ以上の条件(condition)を含めることもできます

下のポリシー例では、

  • このポリシーには、エフェクトとスコープが書かれている
  • エフェクトはpermitである
  • スコープは、aliceというUserタイプのプリシパル、updateというアクション、「VacationPhoto94.jpg」というPhotoタイプのリソースを指定している

つまり、このポリシーはaliceというUserが「VacationPhoto94.jpg」というPhotoを更新することを許可しています。ちなみに、このポリシーは条件(後述)を持っていません。

permit(
  principal == User::"alice", 
  action    == Action::"update", 
  resource  == Photo::"VacationPhoto94.jpg"
);

プリンシパルとリソースは、タイプ(type)とIDの組み合わせによって一意に決定されます。上述の例のプリンシパルでは、「User」がタイプで、「alice」がIDになります。ポリシーがプリンシパルまたはリソースを参照する際には、毎回タイプとIDの両方を呼び出す必要があります。 タイプとIDの組み合わせによって一意に決定されたものはエンティティと呼ばれます。プリンシパル、アクション、リソースの全てがCedarではエンティティと表現され、Userタイプのalice、Actionタイプのupdate、PhotoタイプのVacationPhoto94.jpgはエンティティです。

また、タイプはアプリ開発者が自由に名称をつけることができます。上の例ではUserを使っていますが、、PersonCustomerのようにすることができます。

CedarポリシーはCedar評価エンジンで利用されます。認可リクエスト(authorization request)が承認(Allow)されるには少なくとも1つの該当した許可ステートメントが存在することかつ該当する禁止ステートメントが存在しないことが必要です。ポリシーが該当するという状況は、認可リクエスト内の値がスコープと合い、かつ全ての条件が合っていることを指します。

認可リクエストを評価するにはCedarライブラリを使うことができますが、現在Rustにのみ対応しています。正体的には、FFIインタフェースを提供していく予定です。

Foreign function interfaceとは - わかりやすく解説 Weblio辞書

Foreign function interface(フォーリン・ファンクション・インターフェイスFFI)とは、あるプログラミング言語から他のプログラミング言語で定義された関数などを利用するための機構。主に高水準言語からC/C++などの関数やメソッドを呼び出し、OS固有の機能などを利用するために使用されることが多い。

2. 禁止ポリシー

禁止ポリシーは許可ポリシーよりも優先されます。下のように同じ条件でポリシーを書くと禁止ポリシーが優先され、認可リクエストは拒否(Deny)と評価されます。

permit(
  principal == User::"alice", 
  action    == Action::"view", 
  resource  == Photo::"VacationPhoto94.jpg"
);

forbid(
  principal == User::"blice", 
  action    == Action::"view", 
  resource  == Photo::"VacationPhoto94.jpg"
);

AWS IAMポリシーと同じようになっており、優先度の高い順でいくと、

明示的な拒否(認可リクエストが禁止ポリシーが該当している) → 明示的な許可(認可リクエストが許可ポリシーが該当している) → 暗黙的な拒否(認可リクエストにいずれのポリシーも該当していない)

ということです。

3. セット(Sets)

スコープに複数の値をとることができます。下の例のポリシーでは、アクションにセットを使って複数の値を設定しています。

permit(
  principal == User::"alice", 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource == Photo::"VacationPhoto94.jpg"
);

このポリシーは、aliceというUserが「VacationPhoto94.jpg」というPhotoを閲覧、編集、削除することを許可しています。

アクションにセットを定義することができますが、プリンシパルとリソースには定義することができません。

  • セット内の項目の順序は承認の決定に影響しますか?
    • 影響しない
  • プリンシパルのユーザーのセットになるようにスコープを編集すると、構文チェッカーはどのようなエラーを返しますか?
    • 構文チェッカーからのエラー:poorly formed: expected single entity uid or template slot, got a set of entity uids
  • セット内の値が重複してもエラーにはならなかった

4. 未定義のスコープ

セットはアクションには使えますが、プリンシパルやリソースに使うことはできません。

プリンシパルやリソースといったエンティティで広い範囲に該当するポリシーを書くには2つの方法があります。 方法の1つはプリンシパル、リソース、アクションを指定せず、スコープを完全に未定義にすることです。未定義の場合、全てのエンティティに適用されるとみなされます。

下の例ではプリンシパルを未定義にしています。

permit(
  principal, 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource == Photo::"vacationPhoto.jpg"
);

さらに、下のようにアクションとリソースも未定義にして何もかもを許可する寛容すぎるポリシーをつくることができます。

permit(
  principal, 
  action, 
  resource
);

また、未定義のスコープを使って禁止ポリシーを書くこともできます。

forbid(
  principal, 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource == Photo::"vacationPhoto.jpg"
);

5. RBACのためのグループ

ポリシーのスコープの該当範囲を広くする方法の2つ目は、エンティティのグループを定義することです。プリンシパルのグループによって、ロールを使った権限の管理ができるようになります。

permit(
  principal in Role::"vacationPhotoJudges",
  action == Action::"view",
  resource == Photo::"vacationPhoto94.jpg"
);

上の例は、グループとしてロールを用いています。ここでのRole予約語ではないため、UserGroupなど他のエンティティタイプを使うことができます。

グループを使ったポリシーで認可リクエストを評価するには、プリンシパルがこのグループのメンバーであるかを知る必要があります。そのため、アプリケーションは認可リクエストの一部として関連するグループメンバーシップ情報を評価エンジンに提供する必要があります。

エンティティタイプUserのBobは、エンティティタイプRoleのvacationPhotoJudgesと同じくエンティティタイプRoleのjuniorPhotographerJudgesを親に持ちます。

let entities_json = r#"[
    {
        "uid": {
            "type": "User",
            "id": "Bob"
        },
        "attrs": {},
        "parents": [
            {
                "type": "Role",
                "id": "vacationPhotoJudges"
            },
            {
                "type": "Role",
                "id": "juniorPhotographerJudges"
            }
        ]
    },
    {
        "uid": {
            "type": "Role",
            "id": "vacationPhotoJudges"
        },
        "attrs": {},
        "parents": []
    },
    {
        "uid": {
            "type": "Role",
            "id": "juniorPhotographerJudges"
        },
        "attrs": {},
        "parents": []
    }
]"#;
let entities = Entities::from_json_str(entities_json, None).expect("entity parse error");

6. ABACその1

下のポリシーは、条件(condition)を設定したものです。このポリシーでは、どのプリンシパルもどのリソースを閲覧する(Action::"view")ことができますが、それはリソースの属性(attribute)accessLevelが「public」である場合かつプリンシパルの属性locationが「USA」である場合としています。

permit(
  principal,
  action == Action::"view",
  resource
)
when {resource.accessLevel == "public" && principal.location == "USA"};

演算子について

  • 論理積&&)以外に論理和||)が利用できます
  • 等価(==)以外に不等価(!=)が利用できます

属性の型について

  • String
    • 上述のポリシー例で利用しています
  • Boolean
  • Integer
  • Entity ID
    • 例としてはUser::"Alice"
  • Sets
    • 値の集合、[]で表現されます

7. ABACその2

前段落のポリシーでは、属性を静的な値で評価していました。

この段落のポリシーでは、動的な値で評価をおこなっています。リソースの属性ownerプリンシパルの属性idと一致する場合に、どのリソースでも閲覧する(Action::"view")こと、編集する(Action::"edit")こと、削除する(Action::"delete")ことが許可されます。

permit(
  principal, 
  action in [Action::"view", Action::"edit", Action::"delete"], 
  resource 
)
when {
  resource.owner == principal.id
};

8. 条件とコンテキスト

ここまでのポリシーはプリンシパルやリソースの属性を評価してきましたが、ここではそれ以外の、プリンシパルやリソースとは無関係な値をつかったポリシーの書き方を紹介します。

プリンシパルやリソースとは無関係な値をポリシーで使うには、コンテキスト(context)を利用します。コンテキストによって、ポリシーの条件(condition)で追加データを参照できるようになります。コンテキストに追加されるデータ例としては、JWT形式のIDトークンやリクエスト時にしかわからないもの(送信元IPアドレスなど)、必ずしもデータベースに保存されていないものです。

コンテキストをつかったポリシーを下に例示しています。 ポリシーの意味は、aliceというUserがflower.jpgというPhotoを更新する(Action::"update")こと、削除する(Action::"delete")ことを、多要素認証(mfa_authenticated)され、かつ送信元IPアドレスrequest_client_ip)が「222.222.222.222」のときに許可される、というものです。

permit(
    principal in User::"alice", 
    action in [Action::"update", Action::"delete"],
    resource == Photo::"flower.jpg")
when {
    context.mfa_authenticated == true &&
    context.request_client_ip == "222.222.222.222"
};

コンテキストにはアプリケーションが値を設定してあげる必要があります(下のRustの実装参照)。 また、ユーザが詐称できるような値(リクエストパラメータに入っているユーザIDをそのまま使うなど)をポリシーに使うことは避け、署名・検証されたJWT形式のIDトークンからユーザIDを取得するなどの対応をおこなう。

let principal = EntityUid::from_str("User::\"alice\"").expect("entity parse error");
let action = EntityUid::from_str("Action::\"update\"").expect("entity parse error");
let resource = EntityUid::from_str("Photo::\"flower.jpg\"").expect("entity parse error");

let context_json_val: serde_json::value::Value = serde_json::json!({
    "mfa_authenticated": true,
    "request_client_ip": "222.222.222.222",
    "oidc_scope": "profile"
});
let context = Context::from_json_value(context_json_val, None).unwrap();

let query: Query = Query::new(Some(principal), Some(action), Some(resource), context);

エンティティはあくまでリソースとプリンシパルに関するデータを提供するために使われます。コンテキストはそれ以外の他のデータをポリシーで評価するために使われます。

9. スキーマ

プリンシパルやリソースといったエンティティが増えていくと、ポリシーとそれらエンティティの間で構造や名称にズレが生じてエラーが発生するリスクがあります。

これを解消するためにスキーマがあります。スキーマは、エンティティタイプの名称と構造の宣言です。 このエンティティ定義には属性の一覧を含むことができ、各属性は名称とデータ型を定義できます。

スキーマJSON形式で定義します。スキーマは、最上位層のキーが名前空間(namespace)となり、その下に3つの主なプロパティが存在するという形式です。

名前空間とは:名前空間を使うことでスキーマはユニークに定義されます。複数のスキーマに存在する同じ名称の要素を区別することができます(例:HRApp::FileMedicalRecordsApp::File)。

主なプロパティ

  • entityTypesプリンシパルの型とリソースの型を定義します。各エンティティタイプはその特徴を表すシェイプ(shape)を定義します。このシェイプではCedarがサポートするデータタイプを指定し、より複雑な構造を定義します。また、オプションでmemberOfというプロパティによって階層構造を定義します(例:UserはGroupのメンバーである、PhotoはAlbumに属するなど)。
  • actionsプリンシパルがリソースに対して潜在的に実行できる操作を定義します。各アクションではその名称とアクションが適用されるリソースとプリンシパルの一覧を定義します。
  • commonTypesスキーマの複雑なレコード型の型エイリアスを定義するオプションのプロパティです。コードの再利用性を高めるために利用します。下の例ではPersonTypeを定義しておき、Userエンティティでシェイプとして再利用しています。
{
    "PhotoApp": {
        "commonTypes": {
            "PersonType": {
                "type": "Record",
                "attributes": {
                    "age": {
                        "type": "Long"
                    },
                    "name": {
                        "type": "String"
                    }
                }
            },
            "ContextType": {
                "type": "Record",
                "attributes": {
                    "ip": {
                        "type": "Extension",
                        "name": "ipaddr"
                    }
                }
            }
        },
        "entityTypes": {
            "User": {
                "shape": {
                    "type": "PersonType",
                    "attributes": {
                        "employeeId": {
                            "type": "String"
                        }
                    }
                },
                "memberOfTypes": [
                    "UserGroup"
                ]
            },
            "UserGroup": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                }
            },
            "Photo": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                },
                "memberOfTypes": [
                    "Album"
                ]
            },
            "Album": {
                "shape": {
                    "type": "Record",
                    "attributes": {}
                }
            }
        },
        "actions": {
            "viewPhoto": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            },
            "createPhoto": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            },
            "listPhotos": {
                "appliesTo": {
                    "principalTypes": [
                        "User",
                        "UserGroup"
                    ],
                    "resourceTypes": [
                        "Photo"
                    ],
                    "context": {
                        "type": "ContextType"
                    }
                }
            }
        }
    }
}

10. ポリシーテンプレート

これまで見てきたポリシーはプリンシパルとリソースが指定されており、静的なポリシー(static policy)と呼ばれます。

静的なポリシーはすぐに認可決定に利用できますが、プリンシパルとリソースが既に指定されてしまっているため、同じアクションでもプリンシパルとリソースが異なるポリシーをたくさん作る必要が出てきます。

ポリシーテンプレート(policy template)はこの問題を解決します。 ポリシーテンプレートではポリシー内のプリンシパルおよびまたはリソースにプレースホルダが使われており、このプレースホルダに特定の値を与えることでテンプレートからポリシーを作成する(インスタンス化する)ことができます。

ポリシーテンプレートの例(?principal?resourceプレースホルダ):

permit(
    principal == ?principal, 
    action in [Action::"readFile", Action::"writeFile"] 
    resource  == ?resource
  );

プレースホルダーには制約条件があります。

  • ポリシーのヘッダ部分でしか利用できない
  • 演算子==またはinの右側でしか利用することができない
  • アクションでは利用できない
  • whenおよびunless句では利用できない

用語集

その他不明な用語があれば、下記を参照してください。

Terms & concepts | Cedar Policy Language Version 2.3 Reference Guide

おわりに

思ったよりもポリシー記述はシンプルな気がします。カンファレンスでもシンプルに表現できることを念頭にしているとあったのでその効果はあったということでしょうか。

Amazon Verified Permissionsで評価をおこなうには、SDKを使ってユーザ情報などを渡す必要があります。Amazon Verified Permissionsを利用する前にデータベースにアクセスして、送信元IPをコンテキストとして格納してとか結構手間がかかる、という印象があります。

RustのSDKがある以外に、JavaFFIがあるらしいです(cedar-policy/cedar-java: Java bindings for the Cedar language)。
実装を見てみればこのあたり色々わかってくるかもしれません。

機会があれば、実際のアプリケーションで利用してみたいです(やらなさそう)。

Node.jsの組み込みモジュールutilを使ってオブジェクトを文字列に変換する(循環参照編)

前に書いた記事の補足、追加で調べた内容です。

s1r-j.hatenablog.com

utilモジュールを使って文字列化する場合、オブジェクトのプロパティが自分自身を参照していると[Circular *1]に変換されると書きました。

自分自身を参照しているオブジェクトは、下のmyselfのように出力されます。

<ref *1> { depth1: { depth2: { depth3: { depth4: { depth5: { depth6: 'deep' } } } } }, string: 'this is string', longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.", number: 123456, array: [ 1, 2, 3, 4 ], error: Error: test error
       at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:22:12)
       at Module._compile (internal/modules/cjs/loader.js:1063:30)
       at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
       at Module.load (internal/modules/cjs/loader.js:928:32)
       at Function.Module._load (internal/modules/cjs/loader.js:769:14)
       at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
       at internal/main/run_main_module.js:17:47, myself: [Circular *1] }

今回は、別のオブジェクトを間にはさんで間接的に自分自身を参照している、つまり循環参照しているオブジェクトがどのように表示されるのかを試しました。

文字列化したオブジェクトは以下です。

const obj = {};
const objProxy = {};
obj.objProxy = objProxy;
objProxy.obj = obj;

出力結果は以下のようになりました。

<ref *1> { objProxy: { obj: [Circular *1] } }

循環参照していても自分自身は[Circular *1]に変換して文字列に出力してくれるため、無限ループになっておかしくなることはないようです。

Node.jsの組み込みモジュールutilを使ってオブジェクトを文字列に変換する

Node.jsを使っているとき、ログ出力やデバッグのためにオブジェクトを文字列に変換したいことがあります。今回、組み込みモジュールであるutilを使うことで、ちょっと強引ながらオブジェクトを文字列に変える方法を知ったので書き残しておきます。

この記事で紹介している実装はGitHubにおいてあります。 ソースコード全体を確認したい場合や実際に動かした場合は参考にしてください。

JavaScriptでオブジェクトを文字列にするときの問題点

JavaではtoStringメソッドがオーバーライドされていることが多く、欲しい情報が含まれた文字列にすることができます。しかし、Node.js(JavaScript)ではtoStringメソッドがオーバーライド実装されていることは稀で、[object Object]といった意味のない文字列が返ってきます。

他には、JSON.stringify()を使うことで文字列にできますが、エラーが発生するケースがあります。 下の実装のように循環している場合、TypeErrorがスローされます。

const obj = {
  message: 'this is object',
};

obj.myself = obj;
console.log(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON

utilモジュールを使うとどうなるか

utilモジュールは先程の問題を解決してくれます。 また、utilモジュールはconsole.logでのオブジェクトの文字列化にも使われています。

この段落ではconsole.logとutilモジュールの2つのメソッドといくつかのオプションを使った文字列化を見ていきます。

今回、文字列にするオブジェクトは以下のとおりです。

const obj = {
    depth1: {
        depth2: {
            depth3: {
                depth4: {
                    depth5: {
                        depth6: 'deep',
                    },
                },
            },
        },
    },
    string: 'this is string',
    longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.",
    number: 123456,
    array: [
        1, 2, 3, 4,
    ],
    error: new Error('test error'),
};
obj.myself = obj;

このオブジェクトの特徴としては、 - 最大で6つの階層があり、 - 長い文字列を値とするプロパティ、 - 数値を値とするプロパティ、 - 配列を値とするプロパティ、 - Errorオブジェクトを値とするプロパティ、 - 自分自身を参照した循環しているプロパティ、 が存在しています。

console.log

まず、参考としてutilモジュールを内部で使っていると言われているconsole.logによる文字列化を見ていきます。

console.log(obj);

結果:

オブジェクトの階層は3つまでは表示され、それ以上は[Object]にされてしまいました。循環しているプロパティは[Circular *1]と表示されました。

<ref *1> {
  depth1: { depth2: { depth3: [Object] } },
  string: 'this is string',
  longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.",
  number: 123456,
  array: [ 1, 2, 3, 4 ],
  error: Error: test error
      at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:22:12)
      at Module._compile (internal/modules/cjs/loader.js:1063:30)
      at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
      at Module.load (internal/modules/cjs/loader.js:928:32)
      at Function.Module._load (internal/modules/cjs/loader.js:769:14)
      at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
      at internal/main/run_main_module.js:17:47,
  myself: [Circular *1]
}

util.format

では、utilモジュールのformatメソッドを使ってみます。 formatメソッドはprintfのようにフォーマット指定子によって用意したパターンに値を渡すことで、オブジェクトを文字列にすることができます。

実装は以下のようになります。

const util = require('util');
// import * as util from 'util';

console.log(util.format('%o', obj));

結果:

オブジェクトの階層は5つまでは表示されるようになりました。それ以上はconsole.logと同じように[Object]となっています。循環しているプロパティは同じく[Circular *1]と表示されました。 配列では配列の長さが追加表示されています。Errorオブジェクトではstackとmessageが追加で表示されるようになりました。

配列とErrorオブジェクトの文字列化については冗長に見えます。

<ref *1> {
  depth1: {
    depth2: { depth3: { depth4: { depth5: [Object] } } }
  },
  string: 'this is string',
  longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.",
  number: 123456,
  array: [ 1, 2, 3, 4, [length]: 4 ],
  error: Error: test error
      at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:22:12)
      at Module._compile (internal/modules/cjs/loader.js:1063:30)
      at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
      at Module.load (internal/modules/cjs/loader.js:928:32)
      at Function.Module._load (internal/modules/cjs/loader.js:769:14)
      at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
      at internal/main/run_main_module.js:17:47 {
    [stack]: 'Error: test error\n' +
      '    at Object.<anonymous> (D:\\DocumentsD\\IT\\gitrepo\\nodejs-module-labo\\util\\index.js:22:12)\n' +
      '    at Module._compile (internal/modules/cjs/loader.js:1063:30)\n' +
      '    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)\n' +
      '    at Module.load (internal/modules/cjs/loader.js:928:32)\n' +
      '    at Function.Module._load (internal/modules/cjs/loader.js:769:14)\n' +
      '    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)\n' +
      '    at internal/main/run_main_module.js:17:47',
    [message]: 'test error'
  },
  myself: [Circular *1]
}

util.inspect

オブジェクトを検証するutilモジュールのinspectメソッドを使ってみます。 おそらくこのinspectメソッドがconsole.logの内部実装に使われているはずです。

const util = require('util');
// import * as util from 'util';

console.log(util.inspect(obj));

結果:

console.logと同じ結果が得られました。

<ref *1> {
  depth1: { depth2: { depth3: [Object] } },
  string: 'this is string',
  longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.",
  number: 123456,
  array: [ 1, 2, 3, 4 ],
  error: Error: test error
      at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:22:12)
      at Module._compile (internal/modules/cjs/loader.js:1063:30)
      at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
      at Module.load (internal/modules/cjs/loader.js:928:32)
      at Function.Module._load (internal/modules/cjs/loader.js:769:14)
      at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
      at internal/main/run_main_module.js:17:47,
  myself: [Circular *1]
}

(おすすめ)util.inspectでオプションを使う

util.inpsectは第2引数にオプションを渡すことができ、文字列化の設定を変更することができます。 このやり方が今回記事で紹介しているオブジェクトの文字列化の方法としては一番良いと思っています。

今回はオプションを使って、 - これまで階層が3つもしくは5つまでしか表示されなかったのを全て表示されるように変更し、 - プロパティごとの改行をなくし、 - 1行あたりの表示文字数の制限をなくして 表示がコンパクトになるようにしてみました。

これによって、とりあえず行数をあまり使わずに情報を全部表示する、ということができます。

const util = require('util');
// import * as util from 'util';

console.log(util.inspect(obj, {
  depth: Infinity,
  breakLength: Infinity,
  compact: true,
}));

結果:

階層が5以上になっても省略されることがなく、プロパティごとの改行もなくなりました。 ただし、Errorオブジェクトのスタックトレースはオプションを無視して改行されました。

<ref *1> { depth1: { depth2: { depth3: { depth4: { depth5: { depth6: 'deep' } } } } }, string: 'this is string', longString: "I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes. I thought what I'd do was, I'd pretend I was one of those deaf-mutes.", number: 123456, array: [ 1, 2, 3, 4 ], error: Error: test error
       at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:22:12)
       at Module._compile (internal/modules/cjs/loader.js:1063:30)
       at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
       at Module.load (internal/modules/cjs/loader.js:928:32)
       at Function.Module._load (internal/modules/cjs/loader.js:769:14)
       at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
       at internal/main/run_main_module.js:17:47, myself: [Circular *1] }

(おまけ)JSON.stringify

おまけとしてJSON.stringifyを使ってエラーが投げられるパターンを載せておきます。

console.log(JSON.stringify(obj));

結果:

C:\mypath\nodejs-module-labo\util\index.js:52
console.log(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON
                 ^

TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    --- property 'myself' closes the circle
    at JSON.stringify (<anonymous>)
    at Object.<anonymous> (C:\mypath\nodejs-module-labo\util\index.js:52:18)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
    at internal/main/run_main_module.js:17:47

utilモジュールのドキュメントの日本語訳

最後にutil.formatとutil.inspectのドキュメントの日本語訳を載せておきます。 この記事で使った実装以外を試すとき等に使ってもらえれば幸いです。

util.format(format[, ...args])

参考:https://nodejs.org/docs/latest-v18.x/api/util.html#utilformatformat-args

引数

  • format: 文字列型。printfのようなフォーマット文字列。
  • args: any型。文字列として表示する値。

戻り値

  • 文字列型。フォーマットされた文字列。

util.format()メソッドは、最初の引数をゼロ個以上のフォーマット指定子を含んだprintfのようなフォーマット文字列として利用し、フォーマットされた文字列を返します。それぞれの指定子は対応する引数から変換された値に置換されます。

  • %o: Object。一般的なJavaScriptオブジェクトフォーマッティングによるオブジェクトの文字列表現。util.inspect(){ showHidden: true, showProxy: true }オプションで利用したものと似ています。これは非列挙可能型のプロパティとプロキシを含む全オブジェクトを表示するでしょう。

util.inspect

https://nodejs.org/docs/latest-v18.x/api/util.html#utilinspectobject-options https://nodejs.org/docs/latest-v18.x/api/util.html#utilinspectobject-showhidden-depth-colors

  • util.inspect(object[, options])
  • util.inspect(object[, showHidden[, depth[, colors]]])

2種類の呼び出しがあり、1つ目のoptionsを使うほうで2つ目の呼び出しもカバーされています。

引数

  • object: any型。JavaScriptのプリミティブ型またはObject
  • options: Object型。
    • showHidden: boolean型。表示するシンボルおよびプロパティを決定する。trueを指定すると、列挙不可能なシンボルおよびプロパティも表示する。デフォルトはfalse
    • depth: 数値型。引数object再帰的に表示するときに展開する階層数。全部展開するならばInfinityまたはnullを指定する。デフォルトは2
    • colors: boolean型。出力にANSIカラーコード形式で色がつく。デフォルトはfalse
    • customInspect: boolean型。util.inspect.customプロパティには関数を設定することができ、表示をカスタマイズすることができるが、その関数を呼び出すか否かを決定できる。falseの場合、呼び出さない。デフォルトはtrue
    • showProxy: boolean型。JavaScriptProxyオブジェクトを表示するとき、targethandlerも含めて表示するかどうかを決める。trueを指定すると表示する。デフォルトはfalse
    • maxArrayLength: integer型。配列を表示するさいの最大の要素数。配列が指定された数よりも多い要素数をもつ場合、その要素は表示されない。全て表示するにはInfinityまたはnullを指定し、0または負の数を指定した場合、全て表示されない。デフォルトは100
    • maxStringLength: integer型。表示する最大の文字数。全て表示するにはInfinityまたはnullを指定し、0または負の数を指定した場合、全て表示されない。デフォルトは10000
    • breakLength: integer型。改行する文字数を指定する(後述のbreakLengthとも連携)。すべて1行で表示するにはInfinityを指定し、後述のcompacttrueまたは1以上の数値を設定する。デフォルトは80
    • compact: boolean型またはinteger型。表示をコンパクトにするかどうか、正確には表示するオブジェクトのプロパティごとに改行するか、指定された数だけを1行にして表示するかを決める(ただし、1行に表示する文字数はbreakLengthによっても制御される)。falseを指定するとプロパティごとに改行する。数値が指定された場合、その数のプロパティを表示するまでに1行の文字数が前述のbreakLengthの値を超えなければ改行せずに表示する。デフォルトは3
    • sorted: boolean型またはFunction型。プロパティ、エントリのソート順を決定する。trueまたはFunction型を指定した場合、オブジェクトの全プロパティ、SetおよびMapの全エントリをソートする。trueであればJavaScriptのデフォルトソート、Function型であればそれを使ってソートする。
    • getters: boolean型または文字列型。ゲッター・セッターの表示を決定する。trueが指定されるとゲッターを表示する。getを指定するとセッターに対応していないゲッターだけを表示する。setを指定するとセッターに対応しているゲッターだけを表示する。デフォルトはfalse
    • numericSeparator: boolean型。数値の表示形式を指定する。trueを指定すると、すべてのbigintとnumberを3桁ごとにアンダースコア(_)で区切る。デフォルトはfalse

戻り値 - 文字列型。objectの文字列表現。

参考情報


追記

オブジェクトを循環参照させたときに、無限ループにならないことを確認したので記事にしました。

s1r-j.hatenablog.com

Babelによるトランスパイル前後のクラスをinstanceofで比較するとfalseになる

Node.jsではBabelを使ってトランスパイルをおこなって、どのWebブラウザでも動作するようにしたり、まだ仕様化されていないJavaScriptの機能を使ったりすることがあります。

Babelによってトランスパイルされる前のファイルに存在するクラスとトランスパイルされた後のファイルに存在するクラスをinstanceofで比較するとfalseが返されます。 トランスパイルしただけなので、同じ型のクラスのはずなのになぜ別のクラスと判定されるんだと疑問に思ったり、テストケースを書いているときに困ったりしました。

しかし、考えてみれば当たり前の話で、トランスパイルをおこなったことで別々のJavaScriptファイルになっていることから同じクラスではない、と判定されているのでしょう。

何が起きたのかと推測を先に書きましたが、ちょっとわかりにくいと思います。 どういうことなのかわかるようなコードを書いたので、一部を抜き出してながら説明します。 全てのコードはGitHubに置きましたのでご参考に。

環境

  • Node.js: v16.20.0
  • @babel/cli: v7.22.5
  • @babel/core: v7.22.5

トランスパイル前後のファイル

まず、Babelのトランスパイル対象となるクラスを見ていきます。

トランスパイルのファイル(src/user.js

class User {
    constructor(name) {
        this.name = name;
    }
}

module.exports = User;

トランスパイルのファイル(dist/user.js

class User {
  constructor(name) {
    this.name = name;
  }
}
module.exports = User;

今回Babelの設定をおこなっていないため、ほぼそのままのコード(インデントの空白の数の変更と空行の削除)がトランスパイルされて出力されてきました。

また、モジュールの直下にindex.jsを用意しており、ここではトランスパイルのファイルを読み込んでいます。

module.exports.User  =  require('./dist/user');

この後は、トランスパイル前のクラス(src/user.js)とトランスパイル後のクラス(index.js)を使っていきます。

トランスパイル前後のクラスをinstanceofで比較するとfalseになる

本題です。この段落では、test/user.test.jsファイルを一部抜き出して説明します。

const BeforeUser = require('../src/user');
const AfterUser = require('../index').User;

トランスパイルのUserクラスはBeforeUserとしてインポートし、トランスパイルのUserクラスはAfterUserとしてインポートして使っています。

前 instanceof 前 および 後 instanceof 後

まず、BeforeUserから生成したオブジェクトをBeforeUserでinstanceofしてみました。 結果は当然trueになります。

it('User object from src/user.js is instanceof src/user.js', () => {
    const meg = new BeforeUser('meg');
    expect(meg instanceof BeforeUser).to.be.true; // Passing!
});

AfterUserから生成したオブジェクトをAfterUserでinstanceofしても同じく結果はtrueです。

前 instanceof 後

次に、BeforeUserから生成したオブジェクトをAfterUserでinstanceofしてみます。 結果はfalseになります。

it('User object from src/user.js is not instanceof index.js(dist/user.js)', () => {
    const rebecca = new BeforeUser('rebecca');
    expect(rebecca instanceof AfterUser).to.be.false; // Passing!
});

後 instanceof 前

さらに、前の段落の逆となるAfterUserから生成したオブジェクトをBeforeUserでinstanceofしてみます。 結果はfalseです。

it('User object from index.js(dist/user.js) is not instanceof src/user.js', () => {
    const jonah = new AfterUser('jonah');
    expect(jonah instanceof BeforeUser).to.be.false; // Passing!
});

推測:トランスパイル前後で別ファイル=別クラス?

ここからは推測です。

ここまでのコードでわかったように、トランスパイル前後のクラスは異なるクラスとして認識されています。 Babelによるトランスパイルは、元となるファイルから別のファイルを出力します。JavaScriptでは型情報が別にあるわけではないため、別ファイル=別クラスと認識された結果、今回のような現象が生じたと考えています。

TypeScriptであれば、型ファイル(*.d.ts)によってこのような現象は起きないのではないかと(要検証)。

テストで困るケース

前述のような、同じファイル内でトランスパイル前のファイルとトランスパイル後のファイルの両方をインポートすることは実際にはほとんどありません。 ここでは、現実でテストをおこなったさいに困るケースを紹介します。

テスト対象のコードは以下です(src/hello.js)。 トランスパイルのファイルを読み込んでいます。関数の処理としては、与えられた引数のクラスがトランスパイルのクラスであれば、「Hello」と挨拶を返し、そうでなければ「Userじゃないよ」と返します。

const User = require('../index').User;

module.exports = function (user) {
    if (user instanceof User) {
        return `Hello, ${user.name}.`;
    } else {
        return `You are not User.`;
    }
}

テストコードを見てみます(test/hello.test.js)。 前段落と同じく、トランスパイルのUserクラスはBeforeUserとして、トランスパイルのUserクラスはAfterUserとしてインポートしています。

const BeforeUser = require('../src/user');
const AfterUser = require('../index').User;
it('User object from index.js(dist/user.js) is BeforeUser', () => {
    const kate = new AfterUser('kate');
    const greeting = hello(kate);
    expect(greeting).to.equal('Hello, kate.');  // Passing!
});

it('User object from src/user.js is not BeforeUser', () => {
    const david = new BeforeUser('david');
    const greeting = hello(david);
    expect(greeting).to.equal('You are not User.'); // Passing!
});

1つ目のテストケースではトランスパイルのクラスであるため、ソースコードと同じクラスであり、挨拶が返ってきます。 それに対して、2つ目のテストケースではトランスパイルのクラスであり、クラスが一致しないことから「Userじゃない」と言われています。

テストコードでは、トランスパイルのファイルを読み込むことが多いと思っています。なので、2つ目のテストケースのようになり、テストが通らない状態が発生することがあります。

解消方法としては、ソースコードでもトランスパイルのクラスを読み込む(通常であればこうする?)、テストコードでトランスパイルのクラスを読み込むのどちらかをおこないましょう。

おわりに

Babelによるトランスパイル前後のクラスをソースコードとテストコードで混合して読み込むと、同じクラスのつもりなのにinstanceofがfalseになる、と困ることになります。

Babelのようなトランスパイルをおこなうモジュールを使って、instanceofがうまくいかなかった場合にはこのあたりを見直してみてください。

Amazon Verified Permissionsのプレビューリリースの動画を見たのでとりあえずまとめてみた

Amazon Verified Permissionsというアクセス管理サービス(認可エンジンとも)が2023年5月現在プレビューで利用することができます。

サービス自体は2022年12月にプレビューリリースされており、今更ですが、紹介している記事を見かけたので自分でも動画を見たり、公式情報を読んだりしてみました。
例によって備忘録のような記事を作成しました。

見かけた記事は、下の記事です。

AWS、アプリケーション内できめ細かなアクセス制御を実現するポリシー言語「Ceder」と認可エンジンをオープンソースで公開 - Publickey

続きを読む

AWS Lambda Node.js 18ではデフォルトのAWS SDKがv3になっている(そしてv2を使い続ける方法)

AWS LambdaのランタイムでNode.js 18を選ぶとAWS SDK for JavaScriptのバージョンが3になっています。 (LambdaのランタイムとしてNode.js 18が利用できるようになったのは2022年11月なのでこの記事は今更過ぎますね。)

Lambdaではaws-sdkモジュールが内部に含まれているため、node_modulesに含めることなく、AWS SDKを利用することができます。 Node.js 16まではこのAWS SDKのバージョンが2となっていましたが、Node.js 18ではバージョン3となっています。 下に公式ブログからの情報を載せておきます。

Node.js 18.x runtime now available in AWS Lambda | AWS Compute Blog

Up until Node.js 16, Lambda’s Node.js runtimes have included theAWS SDK for JavaScript version 2. This has since been superseded by theAWS SDK for JavaScript version 3, which wasreleased in December 2020. With this release, Lambda has upgraded the version of the AWS SDK for JavaScript included with the runtime from v2 to v3.

以前からAWS SDK for JavaScriptはv2ではなく、v3を使いましょうね、と言われていたのでここで大きく変えてきたという感じでしょうか。 さらに、開発者ガイドからLambdaのNode.jsランタイムの環境を確認してみます。 Lambdaにデフォルトで含まれているSDKはNode.js 18からv3になっています。

Building Lambda functions with Node.js - AWS Lambda

続きを読む

Node.jsでasync-lockを使った排他制御をおこなう

Node.jsはシングルスレッドで動作するため、スレッドセーフや排他制御については考慮しなくてよいと思っていました。イベントループによって実行される処理は1つだけであり、途中で他のスレッドなどから割り込まれることはないからです。 しかし、実際にはI/O待ちやsetTImeout関数のようなタイマーを使った処理や非同期処理により、処理がイベントループが複数にまたがる場合、あいだで別の処理がおこなわれる可能性があります。 (イベントループについてはこの記事が参考になるかもしれないです:描いて理解するイベントループ - Qiita

なので、処理ごとに数を増やすカウンターやシングルトンパターンでのインスタンス生成(Javaだとsynchronizedを使うやつ)には排他制御を組み込む必要があります。

続きを読む