技術向上

プログラミングの学び、気になるテクノロジーやビジネストレンドを発信

GolangでCasbin~アクセス制御ライブラリ~【Go】

What is Casbin


What Casbin does

  • subject(主語)、object(目的語)、action(行動)をベースとして柔軟に「許可」「不許可」を定義することができる。
  • RBAC(役割ベース)モデル(マルチテナント)など多様な権限設定が可能。
  • adminユーザーを定義することができる。
  • ルールの一致検証に使える演算子(関数)を提供。
    • 例えばkeyMatchは「/ foo / barがパターン/ foo *にマッチする」と判定できる。


What Casbin does NOT do

  • ログイン認証。
    • あくまで文字列に対するルールを管理する(read == trueのような)。
  • ユーザー自体の管理や役割(役割自体の定義)の管理。


How it works

  • .conf形式で、構成ファイル(ポリシー記述の仕様書)を作成する。
  • 構成ファイルで定義した仕様に則り、.csv形式で役割、権限を定義する。
  • サポートされている言語のCasbinライブラリを使って、構成ファイルとポリシーファイルを読み込み、trueかfalseを判定する。
  • 各々のコードに、条件分岐によって、trueなら処理を許可する、falseならエラーを返すなどの処理を書く。


以下に、RBACモデルにおける例を書きます。

sample_configuration.conf

[request_definition]
r = sub, dom, obj, act

[policy_definition]
p = sub, dom, obj, act

[role_definition]
g = _, _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act


構成ファイルは、PERM(Policy/Effect/Request/Matchers)に則って記述します([request_definition][policy_definition][policy_effect][matchers]の4つのセクションが必要)。

RBAC(役割ベースのアクセス制御)モデルを利用する場合、セクション[role_definition]も追加します。
 

  • [request_definition]
    • Casbinのライブラリで、構成ファイルとポリシーファイルを読み込んでtrue or falseを判定するEnforce()メソッドの引数を定義します。
    • つまり、「sub(subject)はobj(object)に対してact(action)できますか?」を問い合わせる書式です。


  • [policy_definition]
    • ポリシーファイルに記述する「誰が、何に対して、何ができるか(RBACの場合、テナント(domain)も記述)」というポリシーのフォーマットを定義します。
    • 「誰が、〜」のように書きましたが、それは目的あって、全ての要素は単なる識別するための文字列で、それ自体に意味は持ちません。
      • あくまで、ポリシーファイルの定義とリクエストを付き合わせることで「一致しているかどうか」を確認するときに意味を持ちます。
      • なので、actはreadではなくてGetと書いてもOKです。Enforce()を使う際、[request_definition]のactに当たる部分にhttp.Methodを指定してrequestができるかどうかを判定させることができます。
    • 執筆現在、Casbinは複数のポリシー定義パターンを許容していません。
      • 例えば「dom(domain)を用いる場合」と「dom(domain)を用いない場合」の2つを同時に定義することは、今の所できません。


  • [policy_effect]
    • Effect()などを使うリクエストの内容が、複数のポリシー定義にマッチする場合に、そのリクエストを許可するか拒否するかを定義します。
    • someは「あるかどうか」を意味します。
    • whereで条件を判定します。
    • p.eftは「ポリシーのマッチ結果」を意味し、それが== allowであれば、許可されている、== denyであれば拒否されている、となります。
    • ただし、何でも書けるわけではありません。Casbinの作成者が「それほどの柔軟さは求められない」と判断したためです。次のパターンがハードコードされており、指定することが可能です(詳細)。
      • some(where (p.eft == allow))
      • !some(where (p.eft == deny))
      • some(where (p.eft == allow)) && !some(where (p.eft == deny))
      • priority(p.eft) || deny


  • [matchers]
    • [request_definition][policy_definition]に記載されるのは、「書き方」であって、順番と識別のための情報(名前付きの箱)です。
    • [matchers]で両者を紐づけ、条件判定に使います。
    • +-*/のような算術演算子&&||!の論理演算子を使うことができます。
    • タイトルは複数形でありながら、複数のmatcherを定義することはできません。


  • [role_definition]
    • type(上記例ではg)と要求する個数(少なくとも2つ)を、カンマ区切りで_を使って指定します。
    • 「誰に、どのテナントの、何のポリシーを与えるか」や「誰に、どのテナントの全ての権限を与えるか」などを定義します。
    • 上記のようなことは[matchers]によって判断します。
      • 例えば右記について。m = g(r.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act
      • 上記はg(r.sub, r.dom)の部分で、ポリシーファイルのgで指定されたものについて(例:g, bob, domain2)、1番目の要素(bob)がリクエストのsubと、2番目の要素(domain2)がリクエストのdomと文字列として等しいかをチェックします。

sample_policy.csv

p, admin, domain1, data1, read
p, admin, domain1, data1, write
p, admin, domain2, data2, read
p, admin, domain2, data2, write

g, alice, admin, domain1
g, bob, admin, domain2


pで表記されているポリシー定義と、gで表記されている役割定義は、それぞれ先述した下記の構成ファイルの定義に沿って書かれています。


[policy_definition]
p = sub, dom, obj, act


1行目を解釈すると、
「adminは、domain1で、data1を、readできる」という意味になります。

(matchersが無ければ、あくまでただの識別子ですが、わかりやすさのために書いています)
 

[role_definition]
g = _, _, _


役割定義の1行目を解釈すると、
「aliceは、adminであり、domain1に属する」となります。

(こちらも本来は、順序によって識別するただのマークです。)

 

サーバーサイドのコード

Go(with firebase authentication)を例に解説します。

※ 別ファイル、別パッケージとした方が良い部分がありますが、簡単のため、まとめています。


func main() {
    // 構成ファイルとポリシーファイルを読み込み、CasbinのEnforcerを生成
    authEnforcer, err := casbin.NewEnforcerSafe("./auth_model.conf", "./policy.csv")
    if err != nil {
        log.Fatal(err)
    }

    //依存性 dependency(d)を注入
       //  NewMiddleware(svc Service, CasbinEnforcer: *casbin.Enforcer)の実行など

    // chi.Routerを使ってrouting
    r.With(d.FirebaseAuth.Handle).Route("/hoge", func(r chi.Router) {
        r.Get("/", d.HogeHandler.Get)
    })
}

func (m *Middleware) Handle(next http.Handler) http.Handler {
// 認証を行いたいパスへのアクセスを処理する何かしらのHandlerを想定
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    idToken := m.getAuthorizationHeader(r) // Headerから認証情報であるid token(firebase authentication)を取得するメソッドを別途定義
    if idToken == "" {
            // エラー処理
            return
        }
    okFromCasbin, err := m.Svc.Authentication(ctx, r, m.CasbinEnforcer, idToken)
        if err != nil {
            // unauthorizedエラーを返す
            return
        }
        if okFromCasbin {
            // Casbinからtrueが返されたので、routingを成功させる
            next.ServeHTTP(w, r)
        } else {
            // unauthorizedエラーを返す
            return
        }
    })
}

func NewMiddleware(svc Service, CasbinEnforcer: *casbin.Enforcer) *Middleware {
    return &Middleware{
        Svc: svc,
        CasbinEnforcer: *casbin.Enforcer
    }
}

type Claims struct {
    Role string
}

func (s *service) Authentication(
    ctx context.Context,
    r *http.Request,
    CasbinEnforcer *casbin.Enforcer,
    idToken string,
) (bool, error) {
        claims := Claims{}

        t, err := s.firebaseauthClient.VerifyIDTokenAndCheckRevoked(ctx, idToken)
        if err != nil {
                // エラーログ
                return userID, claims, err
         }

        if role, ok := t.Claims["Role"].(string); ok {
            claims.Role = role
        }
        // contextにClaimを詰める処理
        // casbinにリクエスト
        return CasbinEnforcer.EnforceSafe(claims.Role, r.URL.Path, r.Method)
}


Casbinには
- 設定内容を読み込む - リクエストを送り、「許可」「不許可」を問い合わせる

メソッドがあるので、それを利用すればOKです。

「許可」が返されたら、serve HTTPをし、「不許可」ならUnauthorized(認証されていない)エラーを返します。


参考

Overview · Casbin

Casbin · An authorization library that supports access control models like ACL, RBAC, ABAC for Golang, Java, C/C++, Node.js, Javascript, PHP, Python, .NET (C#), Delphi, Rust, Ruby, Lua, Dart/Flutter and Elixir

Basic Role-Based HTTP Authorization in Go with Casbin - zupzup

Approach Question: Support license with roles · Issue #433 · casbin/casbin · GitHub

Add Permissions Requirement on route? · Issue #33 · casbin/casbin · GitHub

How to implement RBAC custom function which has to read information from database? · Issue #326 · casbin/casbin · GitHub

UnixTimeStampのカラムから日付ごとに集計する【MySQL】

UnixTimeStampで表現されているカラムを用いて、日付ごとに集計を行うクエリ。

SELECT DATE_FORMAT(FROM_UNIXTIME(c.confirmed_at),'%Y/%m/%d') AS confirm_date, COUNT(ci.id) AS quantity FROM carts AS o JOIN cart_items AS ci ON ci.cart_id = c.id WHERE ci.status = 0 GROUP BY confirm_date


Table

  • carts
    • date_timeをunix時間で保持
  • cart_items

の場合、次のような結果が返されます。

+-------------+---------+
| confirm_date | quantity |
+-------------+---------+
| 2019/11/16       |            3  |
| 2019/12/25      |            1   |
+-------------+---------+


Group Byに指定されたカラムの昇順で並びますが、 降順にしたい場合は、インナークエリ(パフォーマンスが悪くなります)かコードで処理します。


Cloud Pub SubをトリガーにしたCloud Functionsをデプロイする【Go】【GCP】

Cloud Pub/Sub

Cloud Pub/Subは、GCPの環境を使って、手軽にPublisherとSubscriberを構成できる、Googleによるクラウドツールです。

概要については、こちらに記載していますのでご覧ください。

Cloud Functions

Cloud Functionsは、GCPの環境を使って、手軽に関数単位でデプロイできる、Googleによるクラウドツールです。

概要については、こちらに記載していますのでご覧ください。


構成

あるTokenにPublishされたものをSubscribeすることをトリガーに、 Cloud Functionsとしてデプロイした関数を起動することができます。

Pubilsh
↓
あるTopic
↓ Subscribe
Cloud Functions上にデプロイした関数の発動


必要な設定は、Cloud Functionsに関数をデプロイするときに、トリガー(何をきっかけに関数を発動するか)にPub/SubのPublishイベントを指定し、あらかじめ設定しておいたPub/SubのTopicを指定することです。

ソースコードの記述

今回は関数をGolangで記述する例を紹介します。

// グローバルに定義した型や定数、変数は、関数がデプロイされている状態であれば、確保され続ける

type pubSubMessage struct { // この中から必要なものだけ指定すればOK
    Attrs       map[string]string `json:"attributes"`
    Data        []byte            `json:"data"` // base64-encoded data  送られるメッセージ
    ID          string            `json:"messageId"`
    PublishTime string            `json:"publishTime"`
}

type publishedData struct {
    Orders     []*orderData `json:"orders"`
}

type orderData struct {
    OrderID                 string `json:"order_id"`
    OrderStatus             string `json:"order_status"`
}

type got struct {
    Orders     []*gotOrder `json:"orders"`
}


type gotOrder struct {
    ID int64 `json:"id"`
}


var ordersURL *url.URL

func init() {  // 関数内で繰り返し使用する環境変数をinitで最初に読み込む。関数デプロイ時に実行される
    var err error
    ordersURL, err = url.Parse(os.Getenv("INTEGRATION_URL"))
    if err != nil {
        panic("error occurred parsing INTEGRATION_URL")  もし、指定した環境変数が存在しなければ、デプロイを失敗させる
    }
    ordersURL.Path = path.Join(ordersURL.Path, "orders")
}

func UpdateOrder(ctx context.Context, m pubSubMessage) error { // context.ContextとPub/Subメッセージを引数に取るのが決まった形式。 errorを返すことで、コンソール画面で一覧できる。
    var pData publishedData

    if err := json.Unmarshal(m.Data, &pData); err != nil {
        return fmt.Errorf("json unmarshal error, from m.Data to publishedData: %v", err)
    }

    orders := pData.Orders
    client := http.Client{Timeout: 15 * time.Second}

    for _, order := range orders {
        if err := func() error { // for文の中で、defer でBodyをCloseする際、オーバーヘッドを減らすために即時実行関数を使用
            req, err := createRequest(ctx, http.MethodGet,
                ordersURL.String(), nil)
            if err != nil {
                return err
            }

            q := req.URL.Query()
            q.Set("ec_order_id", order.OrderID)
            req.URL.RawQuery = q.Encode()

            resp, err := client.Do(req)
            if err != nil {
                return fmt.Errorf("http GET request error: %v", err)
            }
            defer resp.Body.Close()

            isSuccess := http.StatusOK <= resp.StatusCode && resp.StatusCode <= http.StatusPartialContent  // 206までなら成功と扱う
            if !isSuccess {
                return fmt.Errorf("failed to request GET: status code = %d", resp.StatusCode)
            }

            var got gotModel
            err = json.NewDecoder(resp.Body).Decode(&got)  / Streamのまま扱うことで、メモリ効率を下げない(↔︎ioutil.ReadAllからのjson.Unmarshal)
            if err != nil {
                return fmt.Errorf("json decode, from response body to got:  %w", err)
            }

            reqData, err := json.Marshal(order)
            if err != nil {
                return fmt.Errorf("json marshal error, order: %v", err)
            }
            for _, gotData := range got.Orders {
                ordersURL.Path = path.Join(ordersURL.Path, strconv.FormatInt(gotData.ID, 10))
                req, err = createRequest(ctx, http.MethodPut,
                    ordersURL.String(),
                    bytes.NewReader(reqData))
                if err != nil {
                    return err
                }

                resp, err = client.Do(req)
                if err != nil {
                    return fmt.Errorf("http PUT request error: %v", err)
                }
                defer resp.Body.Close()

                isSuccess = http.StatusOK <= resp.StatusCode && resp.StatusCode <= http.StatusPartialContent
                if isSuccess {
                    if _, err := io.Copy(ioutil.Discard, resp.Body); err != nil {  // bodyの結果を利用しないので、切り捨てる
                        log.Printf("[WARN] discard response body error: %v", err)
                    }
                } else {
                    b, err := ioutil.ReadAll(resp.Body)  // なぜエラーになったのかを知りたいため、bodyを読む
                    if err != nil {
                        log.Printf("failed to read response body, PUT: %v", err)
                    }
                    return fmt.Errorf("failed to request PUT: %s", string(b))
                }
                ordersURL.Path = path.Dir(ordersURL.Path)  // 最後の"/"の後に指定されたパスを切り捨てる、pathのメソッド(ファイルに対するディレクトリの意味)。
            }
            return nil
        }(); err != nil {  // for文の中のdefer resp.Body.Close()のためにifで囲ったので、ここでエラーをキャッチする必要がある
            return err
        }
    }
    return nil
}


func createRequest(
    ctx context.Context,
    method, uri,
    body io.Reader,
) (*http.Request, error) {
    req, err := http.NewRequest(method, uri, body)
    if err != nil {
        return nil, fmt.Errorf("create request error: %v", err)
    }
    req = req.WithContext(ctx)  // forのなかで、reqは繰り返し生成されるので、header、contextを都度設定
    req.Header.Add("user-agent", "UpdateOrder/1.0")
    req.Header.Add("content-type", "application/json; charset=utf-8")
    return req, nil
}


デプロイ

下記のようにコマンドをターミナルで実行し、デプロイします。

gcloud functions deploy <関数名> --runtime go111  --trigger-resource <topicID project/~などは不要> --trigger-event google.pubsub.topic.publish --region <リージョン。例:asia-northeast1> --set-env-vars <好きな変数名>=<好きな値> --timeout <任意。タイムアウトに指定する秒数。Maxは540>


このコマンドは、デプロイする関数が書かれているファイルと同じディレクトリで実行します。
関数名は先ほどのソースコードで言えば、UpdateOrderとなります。GCPのプロジェクト上で一意である必要がありますが、--entry-pointオプションを使って、ソースコード上の同じ関数を別名でデプロイすることも可能です。

タイムアウトはデフォルトで1分です。処理時間がかかりそうな関数の場合は、--timeoutオプションを指定して、伸ばしましょう。

--set-env-varsオプションを使えば、ソースコード上で使える環境変数を設定できます。


Pub/Sub、Cloud Functions共に、そこまで使用しなければ、無料で使えます。
Webサービスのアカウント登録後のメール送信など、ある程度時間のかかる処理をPublish Subscriber構成にしてUXを毀損させないようにするなど、手軽に試してみるといいと思います。


Cloud Pub/Sub概要【GCP】 - 技術向上

Cloud Functions概要【GCP】 - 技術向上

Cloud SQL Proxyで接続する【GCP】

Cloud SQL Proxyとは

Cloud SQL へ接続するにはいくつか方法があります(公式)。

その中でもCloud SQL Proxyを使った接続は、簡単にセキュアな接続ができる便利な方法です。
パブリックIP接続を構成したり、SSL/TLSを構成する手間が省けます。

プロキシサーバーをローカル環境で実行することによって機能します。
Cloud SQLに接続するアプリケーションは、セキュアな通信路を使用してプロキシサーバーにアクセスし、Cloud SQLに接続します。


インストール

前段で、必要があれば「Cloud SQL API を有効にする」を実行します。

プロキシをインストールします。

$ curl -o cloud_sql_proxy https://dl.google.com/cloudsql/cloud_sql_proxy.darwin.386


cloud sql proxyの実行権限を設定します(Linux)。

$ chmod +x cloud_sql_proxy


接続

$ ./cloud_sql_proxy -instances=<INSTANCE_CONNECTION_NAME>=tcp:3306 -credential_file=<PATH_TO_KEY_FILE>
// 例↓
$ ~/cloud_sql_proxy-dir=/cloudsql -instances='team-a:asia-northeast1:team-a=tcp:3306' -credential_file=credentials_info.json


cloud_sql_proxyのバイナリファイルや、credential_fileの格納場所は、皆さんの環境次第だと思いますので、調整が必要です。

これによって、Cloud SQLに接続ができている状態になります。

My SQL接続

これはMy SQLを使用している場合の話になりますが、接続には次のようなコマンドを実行します。

$ mysql -u <ユーザー名> -p --host <ホストIP>
例↓
$ mysql -u user-a -p --host 127.0.0.1

実行後、パスワードの入力を求められるので、適切なものを入力します。

このユーザー名とパスワードは、Cloud SQLインスタンスに対して作成したユーザー名とパスワードのセットを意味します。


POSTなどの際、JSONの形式をチェックしつつも、bodyの指定は任意とする方法【Go】

POSTでbodyの入力を任意としたいけど、JSONの形式がちゃんとあっているかエラーハンドリングしたい場合。

func decodeJSON(r *http.Request, dst interface{}) error {
    dec := json.NewDecoder(r.Body)
    return dec.Decode(dst)
}

create(w http.ResponseWriter, r *http.Request) {
  if err := decodeJSON(r, &param); err != nil && err != io.EOF {
      // エラーハンドリング
        return
    }
  // 何かしらの処理
}


ポイントは、decodeJSONが返す、JSON形式のエラーチェックの他に、io.EOFのエラーでないことをチェックしている点です。

io.EOFは、内容の終端でないかをチェックするものですが、このエラーの場合、bodyが指定されていないことになります。
そのエラーではないことを確認することで、bodyの指定を任意とすることができるのです。


コマンドでSFTPによるサーバ接続【Linux】

SFTPとは

FTPSSHバージョンです。簡単に言えばセキュアなFTPということになります。
FTPはFile Transfer Protocolの略で、ファイルをやり取りする際の取り決めです。

SSHによって暗号化された通信路を使ってファイルをやり取りすることになるので、機密情報を扱う場合や多くの企業向けシステムで利用されます。

Linuxコマンド

接続

$  sftp -i <秘密鍵のパス> -oPort="<サーバーのPort>" <公開鍵のユーザー名>@<サーバーのIP>

秘密鍵のパスは大抵「~/.ssh」フォルダに格納されると思うので、鍵の名前が「id_rsa」であれば「~/.ssh/id_rsa」となります。

サーバーのポートは、SSHの場合「22」が世界で共通のデフォルトポートですが、世界に知られた状態ではまずい、ということもあってカスタムポートを開くことが一般的です。例えば22600などを指定します。

鍵の作成

下記のようなコマンドで公開鍵と秘密鍵を作成したら、そのユーザー名を上記接続の際に使用します。

$ ssh-keygen -t <暗号方式(rsaなど)> -f <鍵のパス(ファイル名称も拡張子なしで指定)> -C <ユーザー名>


作成した公開鍵の内容は、接続先サーバーの~/.ssh/authorized_keysに追記されている必要があります。それによって、接続元を認識しているわけです。秘密鍵を接続の際に渡すことで、接続が安全であることを確認しています。

ファイルのアップロードとダウンロード

sftp接続を完了した状態を前提に話します。

手順があります。

  1. アップロードするディレクトリ、またはダウンロードする元のディレクトリを指定
  2. ファイルをアップロード、またはダウンロードするファイル名を指定して実行


【アップロードの場合】
1:

sftp> cd <ディレクトリまでのパス>

2:

sftp> put <ファイル名>


【ダウンロードの場合】
1:

sftp> cd <ディレクトリまでのパス>

2:

sftp> get <ファイル名>


また、コマンドを実行している「ローカル」のカレントディレクトリを下記コマンドで確認できます。

sftp> lpwd



Cloud SchedulerでHTTPエンドポイントを叩く【GCP】

Cloud Schedulerとは

  • App Engin(GCP)、PubSub(GCP)、HTTPのエンドポイントを決められた時間に起動することができます。
  • スケジュールをJobと呼びますが、3つのJobまでならば無料です。


Jobを作成する

GCPコンソール(画面)から行うか、gcloudコマンドを利用します。
gcloudコマンドでしか設定できないものも多いので、コマンドでの設定をお勧めします。

なお、コマンドでしか設定できない内容の場合、コマンドで設定した後にコンソール画面から更新してしまうと、
コマンドでしか設定できない内容が失われましたので、注意が必要です。

サンプル

下記サンプルは

というjobを作るコマンドです。

$ gcloud scheduler jobs create http this_is_job_name  --schedule="00 9 * * *" --uri="http://31.20.90.11:3001/tests" --headers A-KEY=12 --time-zone="Asia/Tokyo"


Headersで設定した内容はコマンドでしか設定できません(2019年11月時点)。さらにlogに出力されないため、確認することはできません。( 公式より)