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)できますか?」を問い合わせる書式です。
- Casbinのライブラリで、構成ファイルとポリシーファイルを読み込んでtrue or falseを判定する
[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]
[role_definition]
- type(上記例では
g
)と要求する個数(少なくとも2つ)を、カンマ区切りで_
を使って指定します。 - 「誰に、どのテナントの、何のポリシーを与えるか」や「誰に、どのテナントの全ての権限を与えるか」などを定義します。
- 上記のようなことは
[matchers]
によって判断します。
- type(上記例では
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(認証されていない)エラーを返します。
参考
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
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
の場合、次のような結果が返されます。
+-------------+---------+ | 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することをトリガーに、
CloudFunctionsとしてデプロイした関数を起動することができます。
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 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, ¶m); err != nil && err != io.EOF { // エラーハンドリング return } // 何かしらの処理 }
ポイントは、decodeJSONが返す、JSON形式のエラーチェックの他に、io.EOFのエラーでないことをチェックしている点です。
io.EOFは、内容の終端でないかをチェックするものですが、このエラーの場合、bodyが指定されていないことになります。
そのエラーでないことを確認することで、bodyの指定を任意とすることができるのです。
コマンドでSFTPによるサーバ接続【Linux】
SFTPとは
FTPのSSHバージョンです。簡単に言えばセキュアな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:
sftp> cd <ディレクトリまでのパス>
2:
sftp> put <ファイル名>
【ダウンロードの場合】
1:
sftp> cd <ディレクトリまでのパス>
2:
sftp> get <ファイル名>
また、コマンドを実行している「ローカル」のカレントディレクトリを下記コマンドで確認できます。
sftp> lpwd
Cloud SchedulerでHTTPエンドポイントを叩く【GCP】
Cloud Schedulerとは
Jobを作成する
GCPコンソール(画面)から行うか、gcloudコマンドを利用します。
gcloudコマンドでしか設定できないものも多いので、コマンドでの設定をお勧めします。
なお、コマンドでしか設定できない内容の場合、コマンドで設定した後にコンソール画面から更新してしまうと、
コマンドでしか設定できない内容が失われましたので、注意が必要です。
サンプル
下記サンプルは
- HTTP"http://31.20.90.11:3001/tests"を毎朝9時(cron指定)にキックする
- headerに
A-KEY=12
を指定 - タイムゾーンはAsia/Tokyo
- 名前はthis_is_job_name
という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に出力されないため、確認することはできません。(
公式より)