データベースの固定パスワードをなくす

プラットフォームチームの菅原です。

カンムのサービスで使われている各種アプリケーション(Goアプリ・管理アプリ・Redash等)では、データベースに接続する場合に一般的なパスワード認証を使っていることが多いです。

しかし、パスワード認証はパスワード漏洩のリスクやパスワード管理の手間があり、また要件によっては定期的なパスワードの変更も必要になってきます。 単純な方法で安全にパスワードをローテーションしようとすると、新しいDBユーザーを作成し、アプリケーションの接続ユーザーを変更し、さらに必要であれば元のDBユーザーのパスワードを変更して、接続ユーザーを元に戻す…などのオペレーションが必要になります。

そこで、AWS RDS(PostgreSQL)の「Secrets Managerによるマスターユーザーパスワードのパスワード管理」と「IAMデータベース認証」を利用してシステムから固定パスワードをなくすようにしてみました。

Secrets Managerによるマスターユーザーパスワードの管理

docs.aws.amazon.com

「Secrets Managerによるマスターユーザーパスワード管理」はRDSのマスターユーザーパスワードをSecrets Managerに管理させて定期的にパスワードをローテーションさせる機能です。 パスワードを管理するSecretは自動的に作成されるので、RDS側の設定を変更するだけで機能は有効になります。

terraformでの設定は以下のようになります。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  manage_master_user_password = true

  # aws secretsmanager get-secret-value \
  #   --secret-id $(
  #    aws rds describe-db-clusters \
  #      --db-cluster-identifier my-db \
  #      --query 'DBClusters[0].MasterUserSecret.SecretArn' \
  #      --output text
  #   ) \
  #   --query SecretString --output text
}

Secret自体は自動作成されるのですがローテーション間隔やポリシーは管理したいので、作成されたSecretをterraformにインポートします。

resource "aws_secretsmanager_secret_rotation" "my_db" {
  secret_id = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  rotation_rules {
    automatically_after_days = 7
  }
}

resource "aws_secretsmanager_secret_policy" "my_db" {
  secret_arn = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "secretsmanager:GetSecretValue"
        Condition = {
          ArnNotEquals = {
            "aws:PrincipalArn" = "(アクセスを許可するIAMロール)"
          }
        }
        Effect    = "Deny"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

オンラインの設定変更でも特にダウンタイムが発生するようなこともなく、簡単にマスターユーザーパスワードのローテーションを自動化することができました。

IAMデータベース認証

docs.aws.amazon.com

「IAMデータベース認証」はパスワードの代わりに一時的な認証トークンを生成して許可されたIAMロールからデータベースに接続できるようにする機能です。認証トークンの有効期限は15分で、データベースに接続できれば基本的にコネクションは維持されます。

マスターユーザーパスワードと違ってアプリケーションで使われているので、アプリケーション側の修正も必要になります。

RDS・IAMの設定

「IAMデータベース認証」を有効にするには、まずRDSの設定を変更します。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  iam_database_authentication_enabled = true
}

PostgreSQLの場合、接続するDBユーザー(ロール)にrds_iamロールを付与します。

GRANT rds_iam TO app_db_user;

rds_iamを付与するとIAM認証以外では接続できなくなるので注意が必要です。 IAM認証を有効化する際には、IAM認証用のユーザーを新しく作成して、アプリケーションの接続ユーザーをそちらに変更するようにしました。

DBに接続するEC2インスタンスやECSタスクのIAMロールにはrds-db:connectの権限を付与します。

resource "aws_iam_role_policy" "app_db_connect" {
  role = aws_iam_role.app.name
  name = "db-connect"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "rds-db:connect"
        Resource = "arn:aws:rds-db:ap-northeast-1:123456789012:dbuser:${aws_rds_cluster.my_db.cluster_resource_id}/app_db_user"
      }
    ]
  })
}

アプリケーション側の変更

Goアプリ

database/sqlsql.Open()はコネクションプールを返すので、sql.Open()を呼び出すタイミングと実際にデータベースに接続するタイミングは異なります。IAM認証を使う場合、実際にデータベースに接続するタイミングで認証トークンを生成するように設定する必要があります。

PostgreSQL用のドライバjackc/pgxではstdlib.GetConnector()にオプションを渡すことでデータベース接続のコールバックを設定することができます。

実際のコードは以下のような感じになりました。

func ConnectDB(dsn *url.URL, iamAuth bool) (*sql.DB, error) {
    opts := []stdlib.OptionOpenDB{}

    if iamAuth {
        host := dsn.Hostname()
        port := dsn.Port()
        username := dsn.User.Username()

        if !strings.HasSuffix(host, ".rds.amazonaws.com") {
            var err error
            host, err = net.LookupCNAME(host)

            if err != nil {
                return nil, err
            }

            host = strings.TrimSuffix(host, ".")
        }

        // import "github.com/jackc/pgx/v5/stdlib"
        opts = append(opts, stdlib.OptionBeforeConnect(func(ctx context.Context, cc *pgx.ConnConfig) error {
            awscfg, err := config.LoadDefaultConfig(ctx)

            if err != nil {
                return err
            }

            // import "github.com/aws/aws-sdk-go-v2/feature/rds/auth"
            token, err := auth.BuildAuthToken(ctx, host+":"+port, awscfg.Region, username, awscfg.Credentials)

            if err != nil {
                return err
            }

            cc.Password = token
            return nil
        }))
    }

    cfg, err := pgx.ParseConfig(dsn.String())

    if err != nil {
        return nil, err
    }

    // import "github.com/jackc/pgx/v5/stdlib"
    connector := stdlib.GetConnector(*cfg, opts...)
    db := sql.OpenDB(connector)
    return db, nil
}

データベースのエンドポイントにはCNAMEのエイリアスをつけているので、net.LookupCNAME()で実際のエンドポイントを取得しています。

Djangoアプリ

管理用のDjangoアプリはlabd/django-iam-dbauthで対応しました。

DATABASES = {
    "default": {
        "HOST": "<hostname>",
        "USER": "<user>",
        "NAME": "<db name>",
        "ENGINE": "django_iam_dbauth.aws.postgresql",
        "OPTIONS": {
            "use_iam_auth": True,
            "sslmode": "require",
            "resolve_cname_enabled": True,
        }
    }
}

CNAMEの解決機能が実装されているのが便利です。

DBマイグレーション

GoアプリのDBマイグレーションにはAlembicを使っています。 データベースのパスワードは環境変数経由でAlembicに渡されます。

環境変数で認証トークンを渡す場合、AWS CLIで生成することが多いのですが「AWS CLI一式をDockerイメージに入れたくない」「ホストやユーザー名の渡し方を簡潔にしたい」「CNAMEの解決をしたい」といった理由から、認証トークンを生成するCLI rdsauthを作成しました。

github.com

rdsauthはDB URLを渡すと認証トークンを生成します。また-eオプションをつけることでexport PGPASSWORD=...の形式で出力することもできます。

rdsauthを使って、以下のようにDBマイグレーションを実行するようにしました。

export DB_USER=...
export DB_HOST=...
export DB_PASSWORD=$(rdsauth postgres://${DB_USER}@${DB_HOST})

if [ "$MIGRATE" = "true" ]; then
  python manage.py migrate
else
  python manage.py showmigrations
fi

AlembicをIAM認証に対応させる上で少しつまずいたのがconfigparserのエスケープです。 env.pyで以下のようにしてDB接続情報を渡すことがあると思うのですが、configは内部的にconfigparserを使っているため%エスケープが必要になります。

# dbpass = os.environ.get("DB_PASSWORD") # NG
dbpass = os.environ.get("DB_PASSWORD").replace("%", "%%")
config.set_section_option("alembic", "DB_PASSWORD", dbpass)

connectable = engine_from_config(
    config.get_section(config.config_ini_section),
    # ...
)

psql

基本的にpsqlで直接データベースに接続することはないのですが、どうしてもpsqlでの作業が必要になることがあります。 そのための作業用ユーザーもIAM認証で接続するようにしました。

psqlの接続情報は.pg_service.confで管理されており、$(rdsauth -e postgres://..) ; psql service=my-dbと実行すればデータベースに接続することができるのですが、rdsauthの接続情報は.pg_service.confから自動的に取得してほしかったので簡単なラッパースクリプト pxを作成しました。

#!/bin/bash
PC_SERVICE_CONF=~/.pg_service.conf

if [ $# -eq 0 ]; then
  echo "usage: px <service> [extra-args ...]"
  echo -e "\nservice:"
  grep '^\[' $PC_SERVICE_CONF | tr -d '[]' | sed 's/^/  - /'
  exit 0
fi

set -eo pipefail

NAME=$1
shift

JSON=$(ini2json $PC_SERVICE_CONF | jq --arg name "$NAME" '.[$name]')

if [ "$JSON" = "null" ]; then
  echo "error: service not found - $NAME"
  exit 1
fi

DB_HOST=$(echo "$JSON" | jq -r '.host')
DB_PORT=$(echo "$JSON" | jq -r '.port')
DB_USER=$(echo "$JSON" | jq -r '.user')

$(rdsauth -e "postgres://${DB_USER}@${DB_HOST}:${DB_PORT}")

psql service="$NAME" "$@"

px my-dbを実行することでIAM認証を意識せずにpsqlでデータベースに接続できます。

Terraform(PostgreSQL)

PostgreSQLのロールはterraform(cyrilgdn/terraform-provider-postgresql)で管理しているので、これもIAM認証で接続するようにしました。

プロバイダーにはrdsauthを使って環境変数経由で認証トークンを渡すこともできるのですが、aws_rds_iam_authというオプションを有効にすればプロバイダー内部で認証トークンを生成してくれます。

terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = ">= 1.25"
    }
  }
}

variable "aws_rds_iam_auth" {
  type    = bool
  default = false
}

provider "postgresql" {
  # ...
  aws_rds_iam_auth = var.aws_rds_iam_auth
}

Redash

Redashは、Redash本体のデータベースへの接続とPostgreSQLデータソースの接続でIAM認証をすることになりますが、残念ながら今のところどちらもIAM認証には対応していません。

しかし、修正自体はそれほど難しくはないのでPull Requestを作成しました。マージされることを祈るばかりです。

github.com github.com

まとめ

AWS RDSの機能を使って固定パスワードをなくす話を書きました。

今までは積極的にこれらの機能を使ってこなかったのですが、やってみると特に問題なく導入することができました。 パスワードのローテーション作業は気をつかう手間のかかる作業なので、今後はなるべくデフォルトで有効にして運用コストを下げていきたいと考えています。

SAML Group Mappingを使ったEntra ID+Datadogのロール自動割り当て

プラットフォームチームの菅原です。

カンムではサービスのモニタリングにDatadogを利用しており、その管理はプラットフォームチームが担っています。

DatadogへのログインはMicrosoft Entra ID(旧AzureAD)を使ったシングルサインオンで行うため、DatadogのアカウントはEntra IDで一元管理されているのですが、Datadogのロールの割り当ては自動化されておらず、引き続き依頼を受けてプラットフォームチームが割り当てを行う状態でした。

カンムで使っているDatadogのロールはEntra IDのグループから一意に割り当てられるため、機械的マッピングすることができます。 調べたところ、サインオン時に任意のSAML属性からロールを割り当てるSAML Group Mappingという機能が提供されていたので設定してみました。

docs.datadoghq.com

Entra IDの設定

SAML Group Mappingを利用するにはユーザーが所属するグループに応じてSAML属性(クレーム)の値を動的に変更する必要があります。

最初、user.assignedrolesというソース属性を使ってエンタープライズアプリケーションのグループに割り当てられたロールでクレームの値を変更することを想定していたのですが、user.assignedrolesからはユーザーに割り当てられたロールを取得することができても、グループに割り当てられたロールを取得することができなかったため、「グループごとにクレームの値を変える」という要件を満たすことができず、また、設定も煩雑になってしまうためこちらを使うことができませんでした。

代わりに「条件に基づいてクレームを出力する」機能で要求条件(Claim conditions)で、グループごとの値を設定することでクレームの値を動的に変更することができました。以下はその設定画面です。

要求条件では、最終的に条件にマッチした値がクレームの値になります。

上記の例の場合、プラットフォームグループにマッチした場合、DatadogRoleクレームの値は adminに、開発者グループにマッチした場合はstandardに、どちらにもマッチしなかった場合はreadonlyになります。

(残念ながらterraformのazureadプロバイダでterraformingすることはできなかったため、手動で設定を行いました)

Datadogの設定

Datadog側の設定はterraformで行いました。 DatadogRoleクレームの値に応じて、ロールが設定されるようになっています。

data "datadog_role" "admin" {
  filter = "Datadog Admin Role"
}

data "datadog_role" "standard" {
  filter = "Datadog Standard Role"
}

data "datadog_role" "readonly" {
  filter = "Datadog Read Only Role"
}

# NOTE: Enable Mappings manually
# cf. https://my-org.datadoghq.com/organization-settings/mappings/role-mappings
resource "datadog_authn_mapping" "admin" {
  key   = "DatadogRole"
  value = "admin"
  role  = data.datadog_role.admin.id
}

resource "datadog_authn_mapping" "standard" {
  key   = "DatadogRole"
  value = "standard"
  role  = data.datadog_role.standard.id
}

resource "datadog_authn_mapping" "readonly" {
  key   = "DatadogRole"
  value = "readonly"
  role  = data.datadog_role.readonly.id
}

# NOTE: Entra ID SAML Mapping config
# https://portal.azure.com/#view/Microsoft_AAD_IAM/SamlClaimsEditClaimBladeV2/federatedSsoConfigurationIdentifier/...

まとめ

SAML Group Mappingを使うことでDatadogのアカウントの管理を完全にEntra IDで一元化することができました。

この手の基盤チームありがちな「○○の権限をください」というタスクは一つ一つはたいしたことはないのですが、積み重なると地味に作業の時間を奪われるので、なるべく自動化を進めていきたいところです。

Poolにおける残高管理の設計

こんにちは、エンジニアのpongzuです。 今日は、カンムが提供するプロダクト「Pool」の残高管理設計について書きます。

本記事では、まずPoolがどのような仕組みで動いているのか、投資やウォレットといったサービス仕様を簡単にご説明します。その後、それらをどのようにDB管理するかについて、設計の考え方を順を追って解説していきます。

仕様

Poolは簡単にいうと投資とVisaカードを組み合わせたプロダクトです。 アプリ上で口座開設を行い、ウォレットと呼ばれる機能に入金後、資産運用やカード決済に利用できます。 また、同時にウォレット残高と投資資産がカード利用可能額となり、Visaの加盟店でカード決済をすることができます。以下に仕組みを簡単に図解したものを貼ります。

※この仕様はシステム設計の説明のために簡易化したものです。正式な仕様はサービス紹介・利用規約をご参照ください。

ウォレット・投資・カードの機能について、もう少し詳しく説明します。

ウォレット

ウォレットは、入金の受け皿となる機能です。入金処理が行われると、その金額がウォレット残高として反映されます。また、入金以外にも、キャンペーン対象者へのポイント付与やカード支払いによるポイント還元などもウォレット残高に加算されます。

投資

ユーザーは、ウォレット残高から募集中のファンドへ投資申込を行うことができます。運用開始時にウォレット残高から投資資産へ資金が移行します。運用終了後には、資産(以下「償還額」と呼びます)を次の方法で利用できます。

  • ユーザーが指定する銀行口座への出金
  • 次の募集中ファンドへの再投資

カード

ウォレット残高と投資資産がカード利用可能額となり、Visa加盟店でカード決済を利用できます。 カード利用額は、利用月の月末に確定し翌月末にウォレット残高から支払われます。いわゆる「月末締め・翌月払い」のクレジットカード方式です。ただし、支払い額がウォレット残高を上回る場合に限り、カンムがファンドの持ち分を買い取る形で支払いに充てられます。これにより、ウォレット残高だけでなく、投資資産を活用したカード支払いが可能となっています。

以上のようにしてPoolは資産運用と預け入れた資金の流動性を両立させています。

DB設計

ここからは本題であるデータベース設計について説明します。 Poolの残高管理には、入金履歴・投資運用・カード支払いの3つが関係します。これらのテーブルがどのように関わり合い、残高管理を実現しているのかを順を追って解説します。 まず、主要なテーブルを抜き出してみると、deposit, investment_application, investment, card_payment, wallet_snapshotの5つが挙げられます。

※説明の便宜上すべてのテーブルにuser_idを持たせていますが、実際のリレーションとは異なります。

                                        Table "deposit"
     Column              |           Type           | Collation | Nullable |             Default
-------------------------+--------------------------+-----------+----------+----------------------------------
 id                      | bigint                   |           | not null | nextval('deposit_id_seq'::regclass)
 user_id                 | bigint                   |           | not null | 
 wallet_snapshot_id      | bigint                   |           | not null | 
 amount                  | numeric                  |           | not null | 

depositは入金履歴を管理するためのテーブルです。振込入金口座から入金通知を受けて入金額がウォレット残高に反映されます。 wallet_snapshot_idについては後述します。

                                        Table "investment_application"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 id             | bigint                   |           | not null | nextval('investment_application_id_seq'::regclass)
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null |

investment_application は投資申込を管理するためのテーブルです。募集中ファンドへの投資申込を行った際に作成され、運用開始日を迎えるまではウォレット残高に影響を与えません。また、その期間はキャンセルや金額の変更を行うことも可能です。

                                        Table "investment"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 application_id | bigint                   |           | not null |
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null |

investmentは運用開始後に作成される運用資産を表すテーブルです。investment_applicationのIDをPKとします。このレコードの作成時にウォレット残高から運用資産に残高が移行します。

                                        Table "card_payment"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 id             | bigint                   |           | not null | nextval('card_payment_id_seq'::regclass)
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null | 

card_paymentはカード支払いを管理するためのテーブルです。カード利用額の支払い処理で作成されます。詳しい内容は割愛しますが、実際にはcard_paymentが作成される前段にVisaから届いた決済確定電文を表すテーブルとそれを集計するテーブルが存在し、それを元にcard_paymentが作成されます。支払い額は、まずウォレット残高から差し引き、不足分は投資資産から相殺するという順番で充当されます。

                                         Table "wallet_snapshot"
     Column                 |           Type           | Collation | Nullable |             Default
---------------------------+--------------------------+-----------+----------+----------------------------------
 id                        | bigint                   |           | not null | nextval('card_payment_id_seq'::regclass)
 user_id                   | bigint                   |           | not null |
 previous_snapshot_id      | bigint                   |           |          | 
 amount                    | numeric                  |           | not null |

wallet_snapshot は、ウォレット残高を管理するためのテーブルです。ウォレット残高に変動があるイベントが発生すると、その時点の最新残高を反映した wallet_snapshot が作成されます。 previous_snapshot_idには前回のwallet_snapshotのIDを持たせて残高の推移を表現するようにしました。

以上が主要なテーブルの概要です。次に、ウォレット残高を投資やカードとどのように関連付けるかについて説明します。 ウォレット残高が必ず動くイベントが発生する際にはイベント管理用テーブルにwallet_snapshot_idをFKとして持たせて、そうではない場合は中間テーブルを介してウォレット残高とイベントを紐つけるようにしています。 例えば、depositは入金時に必ずウォレット残高に反映されるため、wallet_snapshot_idをFKとして持ちます。投資とカード支払いについてはウォレット残高から充当された場合のみ、中間テーブルを介してイベントとウォレット残高の変更が紐つけられます。

このようにすることで、資金の流れや原資の管理が容易となりました。 例えば、ウォレット残高や再投資から充当された運用資産の内訳は、investment テーブルとそれに紐づく中間テーブルを辿るクエリで算出可能です。

ここで簡単に実装例も書いておきます。 ウォレット残高から投資を開始するコードはこんな感じです。

※カンムではGoを使っています。

// トランザクションを開始
tx, err := db.Begin()
// 必ずロールバックするように設定
defer tx.Rollback()

// ユーザーIDでロックを取得
if err := LockUser(tx, userID); err != nil {return}

// 申込を取得してInvestmentテーブルに登録
application, err := GetInvestmentApplication(tx, userID)
if err != nil {return}

investment := Investment{
    ApplicationID: application.ID,
    Amount:        application.Amount,
}
if err := investment.Create(tx); err != nil {return}

// 最新のウォレットスナップショットを取得
previousSnapshot, err := GetLatestWalletSnapshotByUserID(tx, userID)
if err != nil {return}

// 新しいウォレット残高を計算し、ウォレットスナップショットを登録
newWalletAmount := previousSnapshot.Sub(investment.Amount)
walletSnapshot := WalletSnapshot{
    UserID:             userID,
    WalletAmount:       newWalletAmount,
    PreviousSnapshotID: previousSnapshot.ID,
}
if err := walletSnapshot.Create(tx); err != nil {return}

// 中間テーブルの作成
r := WalletSnapshotInvestment{
   WalletSnapshotID: walletSnapshot.ID,
   InvestmentID: investment.ID,
}
if err := r.Create(tx); err != nil {return}

// コミット
if err := tx.Commit(); err != nil {return}

スナップショットを用いた他機能への接続は、ロックのとり忘れやスナップショットとイベント管理用テーブルの作成忘れなどに注意する必要がありますが、以下のメリットもあるように思います。

  • ウォレット残高の参照を最新のwallet_snapshotを取得すれば良いので計算量が抑えられる
  • 過去のある時点のウォレット残高の算出が容易となった
  • 残高を都度更新する必要がない
    • たとえば、「ウォレット」というテーブルに残高(balance)を保持し、入出金や支払いのたびにアップデートをかけるといった実装が不要

設計思想

最後に、ウォレット残高をこのような設計で管理することに至った背景についてお話しします。 ウォレットは、ユーザーに必ず1つ割り当てられる、いわばリソース型のエンティティです。 しかし、ウォレット残高自体は入金、投資運用、カード支払いといったイベントの履歴から計算可能であり、以下のようなクエリで表現できます。

SELECT COALESCE(SUM(amount), 0)
    FROM (
      SELECT amount
      FROM deposit
      WHERE user_id = $1
    UNION ALL
      SELECT -amount
      FROM investment
      WHERE user_id = $1
    UNION ALL
      SELECT -amount
      FROM card_payment
      WHERE user_id = $1
  ) AS _

このようにイベントの履歴のみを記録しておく設計では、専用の残高管理用テーブルを用意する必要はありません。このアプローチは実際に弊社の別プロダクト「バンドルカード」でも採用しており、詳細はこちらでご紹介しています。

一方で、 Pool はスナップショット形式ではあるものの残高を実体として管理する設計を採用しています。背景としてはウォレット残高がPoolというサービスを支える基盤的な機能であり、他の機能と疎結合な状態である必要があると考えたからです。 投資やカードといった機能は、法律上ウォレットを介することで初めて成立します。この構造は、Poolというサービスが存在する限り変わることのない重要な要件です。現在は投資とカードが資金の移動先として存在していますが、将来的に「保険」など新しい機能が追加される可能性もあれば、「カード」機能を削除する必要が生じることも考えられます。こうした変化に柔軟に対応するためには、ウォレット残高を他の機能から独立させた疎結合な設計が求められました。

さらに、ウォレット残高はサービスの中心として、単に機能を支えるだけでなく、多様な資金の流れを生み出す役割も果たします。この資金の流れを適切に管理することは、会計上の重要な課題でもあります。そのため、ウォレット残高を独立したエンティティとして定義し、各イベントをそこに紐づけることで、資金の流れを管理できる設計を目指しました。

要するに、ウォレットを他の機能から切り離すことで、法的・会計的な要件に柔軟に対応しつつ、拡張性の高い設計を実現することが今回のポイントでした。 こうした考えを踏まえ、スナップショットを活用して他の機能と接続する設計を選択するという結論に至りました。

まとめ

以上、この記事では、Poolが提供する「ウォレット」「投資」「カード」という機能の概要と、それらをDBでどのように管理しているかについて説明しました。 特に、スナップショットを用いた管理は、残高の推移や他機能への資産の流れを把握しやすくする上で大きな利点がありました。もちろん、残高の設計は一筋縄ではいかず、メリットとデメリットの両面がありますが、現時点ではこの設計を採用して正解だったと感じています。

最後に

カンムではソフトウェアエンジニアを募集しています。ご興味ある方はぜひご連絡ください!

herp.careers

エンジニアによるFintech法律勉強会を開きました

はじめに

ソフトウェアエンジニアの hata です。先日、カンムが提供するプロダクトの一つ、 Pool を取り巻くFintech法律勉強会を開きました。

pool-card.jp

金融は最も規制が厳格で複雑な分野です。金融サービスは、複数の法律から最適な組み合わせを選ぶことが競争優位になりえます(ルールに内在する「余白」を正しく理解し、自らのビジネスに最大限有効・有利に活用するという発想)。

Poolは、他にあまり見ない、投資・決済が一つになったサービスを提供しており、その法的構成も非常にユニークです。

ただし、その法的構成のユニークさから、Poolがどのような法律を組み合わせたプロダクトであるのか、非リーガル関連職のメンバーにとってはとっつきにくい内容となっています。社内に勉強できる資料はいくつかあるのですが、初学者向けにまとまった内容になっていませんでした。

そこで、Poolをとりまく法規制を整理し、非リーガル関連職でもスムーズに理解できるように勉強会を開くことにしました。

なぜソフトウェアエンジニアが法規制について学ぶ必要があるのか

  • 法規制の知識がないと、画期的なプロダクトの改善施策を考えたとしても、その案が今の法的構成では実現不可能な場合があります。法規制について良く知ることで、やってはいけないことを回避できることはもちろん、逆に規制を活用したアイデアが議論の中で生まれるかもしれません。
  • ソフトウェアエンジニアリングは、与えられた制限の中での最適解を見つける活動でもあります。法規制やレギュレーションはその制限の一つであり、それを理解しソフトウェアに落とし込んでいくことでエンジニアとしてのスキルも上げられると考えています。

つまるところ、エンジニアがドメイン知識を身につけることで賢くなると、エンジニア自身もチームも嬉しい、というわけですね。

社内にあるホワイトボードの図を借りると、Fintechに関わる法律をプロダクトに関わるメンバー全員が勉強することで「いこうぜ」と「わーい」の第一象限に当てはまるということです。

社内ホワイトボードに描かれたMECEについての図

勉強会の開催にあたり、気をつけたこと

法律分野の用語や法的整理は非常にナイーブです。JavaJavaScript が異なるように、曖昧なまま理解をすると非常に危険です。金融サービスを作っている以上、法律の誤用は最悪の場合ユーザーの金融資産への侵害につながります。

私はあくまでソフトウェアエンジニアなので、間違った解釈を広めないよう、勉強会に使う資料は事前にリーガルチームにチェックしてもらいました。また、勉強会にも同席してもらい、私が誤ったことを口走ろうものならツッコミを入れてもらうようにしました。

実際の様子

勉強会の様子。弊社はリモート組織なので、Google Meetにて開催。

内容的に全てを公開することはできませんが、以下のような構成で行いました。

  • Pool の各機能(ウォレット・決済・投資)を取り巻く法規制(例:資金決済法、割賦販売法、金融商品取引法)の整理
  • 各法律に関するライセンス(前払式支払手段、第二種金融商品取引業)の解説
  • サービス全体を取り巻く法規制(犯罪収益移転防止法)
  • Poolの機能に関する法的な観点をQ&A形式で掘り下げ

以下は、勉強会の資料の一部です。

おわりに

元々は、Pool のメンバーが増えてきたこともあってオンボーディング的にやろうとしたのがきっかけでした。勉強会参加者の方々からは、好意的な感想をもらえたのでよかったです。

単発の勉強会で限られた時間で全てを理解するのは難しいとは思うのですが、今までは勉強するためのとっかかりすらなかったので、後学のために資料を残す機会を作れたのは個人的に良かったなと思っています。

カンムではソフトウェアエンジニアを募集しています。ソフトウェアエンジニアリングにとどまらず、Fintech最前線に立ってあらゆる知識に触れる機会があるおもしろポジションです。少しだけでも話を聞いてみたいという方でも大歓迎なので、ぜひカジュアル面談でお話ししましょう。

herp.careers

Tauri と Pixoo-64 でオンラインミーティングのカメラの映り込みリスクを軽減する

こんにちは、リモートワークしてますか?私は週7家にいます。エンジニアの岡田です。

この記事はカンム Advent Calendar 2024の24日目です。1 23日目は teshiken さんの「成果を出しつづけるための行動として意識していること」でした。 adventar.org

さて、カンムはフルリモートを導入していまして2、自分はもうかれこれ4年ほどずっと家で仕事をしています。 ミーティングではカメラを ON にすることもままあるのですが、家族も家で仕事をしているので、お互いカメラへの映り込みに注意して生活しています。 ただ、お互いにミーティングの予定は何らかの形で共有しているものの、突発的なミーティングもあったりするので、つい意識せずにフラっと映り込んでしまいそうになることも稀によくある状態です。

…ということで、今回はこのフラっと映り込みリスクを軽減することを目指して、ちょっとした仕組みを作った話をしようと思います。

先人の知恵にのっかる

当然のごとく同じようなことを考えている方がいました。 yoshiori.hatenablog.com

とてもシンプルで良さそうだったので、この考え方にのっかって以下の方針とすることにします。

  • カメラの ON / OFF を検知する
  • カメラの ON / OFF に連動して何か表示する

ただ、記事内のカメラ ON / OFF 検知方針だと、自分の環境の MacBook でうまくいかなかったので、そこはまた以下の知見にのっかって回避することにしました。 ON / OFF のイベントログを監視するだけなので、現在の使用状況まではリアルタイムでわかりませんが、常駐監視する前提ならいったんはこれでも十分そうです3

stackoverflow.com

log stream --predicate '(eventMessage CONTAINS "AVCaptureSessionDidStartRunningNotification" || eventMessage CONTAINS "AVCaptureSessionDidStopRunningNotification")'

つくる

先人とまったく同じものでは面白みが薄いので、少しだけアプローチを変えてみます。

我が家にはピクセルアートフレームの Divoom Pixoo-64 がある4ので、これを活用することにします。 Pixoo-64 には API もあるのでちょうど良さそうです。

また自分以外の家族が使うことを想定して GUI を提供することにします。メニューバーに常駐するアプリだとそれっぽいですし。

そして、せっかくなので使ったことないものをってことで、今回は Tauri を使ってみることにします5。 ついでに初めての Rust 体験もできるので一石二鳥です。

v2.tauri.app

ということで、方針を以下のように定めることにします。

  • カメラの ON / OFF を検知する
  • カメラの ON / OFF に連動して Pixoo-64 にそれっぽい GIF を表示する
  • メニューバーに常駐するアプリとして提供する (Tauri で実装する)

そして、できあがりがこれです。 github.com

つかう

こんな感じで動きます6

カメラをONにすると海苔巻文鳥がON AIRに切り替わります

部屋に入った瞬間に目に飛び込んでくるので、疲れていてもカメラの存在に多少気付きやすくなったんじゃないでしょうか7

ドアをバーンしました

おわりに

久しぶりに個人でそれっぽいものを作った気がします。 家でいつも目にするもの、動きがダイレクトに返ってくるものはやっぱりいいですね。

Rust も今回完全に初めて触ったのですが、そこそこ楽しく開発することができました。 公式ドキュメントをろくに読まず、AIのひねり出すコードに質問しながら知識のつまみ食いをしつつ実装してしまったので、次はちゃんと公式ドキュメントを読んで Rust らしさを学んでからコードをリファクタしていこうと思います。

趣味プログラミングはいいですね。

おわり


  1. 完全に遅刻しました。やっちまいです。
  2. https://team.kanmu.co.jp/8de05f77ac8d487e8cf44f0e74da2c23
  3. 改めて調べてみると Objective-C 経由で検知する方法もありそうなので、objc2 クレートを使ってワンチャンもっと良い感じにできるかもしれません。というか最初から Swift で作っとけばよかった説まであります。
  4. 2台あります。購入数ヶ月で液晶に縦線が入ってしまい、新品交換 & 故障品返送不要となって手元に2台が残りました。故障品には縦線が入っているだけなので普通に使っています。
  5. 他の選択肢として Electron / React Native macOS / Swift でふつうのアプリ開発、もしくはワンチャンExpoもいけたりするかな…とか考えてはいたのですが、初めて度が一番大きいやつが Tauri (w/Rust) だったので、テンションが上がるか否かだけで選びました。
  6. 本当は「ワシじゃよ」な某博士が「カメラがオンじゃよ」と語りかけてくる感じの GIF を作りたかったのですが、諦めて Divoom コミュニティ内に公開されている ON AIR アニメーションを使いました。
  7. 実際は複数人で使用するとたぶんうまく動きません。カメラの ON / OFF イベントオンリーでのトリガーかつ状態の同期をしていないので、複数台で同時にカメラを使っていると、1人がカメラを OFF にすると残りの人がカメラ ON のままでも GIF が表示されなくなってしまいます。…が、めったにないのでいったん気にしないことにします。

バンドルカード開発チームの現在地

カンム 2024年アドベントカレンダーの22日目です。
adventar.org

昨日はAwesomeさんの「銀行員が転職した話」でした。 Awesomeさんは今年カンムへ転職してきてくれたのですが、新規事業である「サクっと資金調達」の立ち上げに関わっていただいています。カンムは今までtoCサービスを中心に事業展開してきましたがこれからはtoBに関しても課題解決をしていきます。
note.com

今回の私の記事ではバンドルカードのプロダクト開発が1年前からどのように変化してきたかを書きます。

私について

私については入社した年に書いた紹介記事があるのでこちらをご覧いただければ幸いです。 michiomochi.com

ここからのアップデートとしては2024年10月にもう少し範囲の広いB2C Divの開発全体を統括する立場のディレクターに役割変更しました。
今の役割で取り組んでいること等について最近書いてもらった記事があるのでこちらもご覧いただければ嬉しいです。
note.com

バンドルカード開発チームとは?

カンムは現在以下の3つのプロダクトを運営しています。 vandle.jp pool-card.jp lp.sakutto-funding.jp

今回の記事ではこの中のバンドルカードを開発するチームについてお話します。

2023年末まではどのようにプロダクト開発を行っていたか?

バンドルカード開発チームでは2023年末まで以下のような形でプロダクト開発を行っていました。

2023年末までのプロダクト開発体制

  • プロジェクト毎にチームを作り、プロジェクトが完了したらチームを解散する
  • どんなプロジェクトを行うかは事業企画チームで立案や検証が行われ、やることが決まった段階でプロジェクトチームが作成される
    • プロジェクトチームはやることが決まった施策を実施することが責任範囲
    • プロジェクトによる効果検証については効果検証を行う環境を整えるところまでがプロジェクトチームの責任範囲で、実際に効果検証を行いレポートする責任を持つのは事業企画チーム
      • 効果検証の結果に応じて事業企画チームの判断の元、追加で新しくプロジェクトを行ったりする
  • プロジェクト完了後はプロジェクトに関する情報は各職能チーム(e.g. バックエンドチーム, CSチーム)に共有され、トラブルや効果検証といったタスクについてはカテゴリー毎に適当な職能チームで対応を行う

良かった点

この開発スタイルで良かった点としては以下の点が挙げられます。

  • やることが明確で議論の余地がないものについて最速で実行できる
    • 何をやるのかを決めるチームとそれを実行するチームを明確にわけることで各領域においての専門性を高め、明確な作業分担ができることで最速で実行が可能になる
    • なので「プロダクト設立初期」や「やることが明確なもの」とは非常に相性が良い

課題に感じていた点

前述した通りこの開発スタイルには良い点があり、実際に今までうまくいっていて会社としても売上を順調に積み上げることができていました。
ただこれからチーム規模や事業規模を拡大していこうとしている現在において、以下の点の課題が大きくなってきました。

  • 何をやるかを決めるプロセスに事業企画チーム以外の視点が入りにくいため、現時点では未知なものであったり多角的な視点での施策策定が難しい
  • 事業企画チーム以外の他メンバーが何を行うかのプロセスに介入するのが難しいので「やらされてる感」を持ちやすい
  • プロジェクト毎にチームが作成され解散するので、各メンバーはどのKPIに対して注力すればよいのかが明確ではない
    • なので各メンバーの目標や評価指標としてはプロジェクトを最速で完了させられたかに収束しがちである
      • 本来評価したいものとしてはプロダクト開発においてのアウトカムに寄与できたのかということなので少し乖離する

2024年からどのようにプロダクト開発を行っているか

2024年からは以下のような形でプロダクト開発を行うよう体制変更を行いました。

2024年からのプロダクト開発体制

  • 職能を横断したチームを作り、そのチームでプロダクト開発を行う
  • 各チームには追うKPIを定める
  • 各チームはプロジェクト毎に解散されるといったことはなく、長期的に存続し定められたKPIを向上させていくことに責任を持つ
    • 短期間でチームが解散することはありませんがメンバーの異動等は適宜発生する
  • 各チームがKPIを向上させるためにやることの決定権は各チームが持つ
    • 何をやるかはチーム内のメンバー全員で意見を出し合い決める
    • 各チームにはPdMがアサインされ、主にPdMがチームのリードを努める
    • 何をどのような優先順位でやるかはPdMがリードして決めるが、決めるための意見やフィードバックについては各メンバーが積極的に行う
    • チームの持つKPIがどれだけ向上したかが各メンバーの評価に繋がる
    • メンバーの目標は所属するチームが持つ目標を上位目標とし、紐づかせて設定する

なぜこのような体制変更を行ったのか

前述した通り明確にやることが見えている状態でそれを実行するにおいては2023年末までの体制のほうが都合が良い点が多くあったと思います。
ですが今後チーム規模を拡大し機能開発や新規事業の立ち上げを複数実施していく中においては、各チームが自律的に動き重要なKPIを向上していける体制を作ることが必要でした。このような体制変更をすることで仮説検証を素早く回していかないとたどり着けないような未知の課題に対しての解決も行っていけるようになるはずです。
具体的にはこの体制変更を行うことで以下のような良い効果を期待しました。

  • プロダクトにおける各KPIの向上について各チームが各メンバーの多角的な視点で主体的に動くことで、改善や計測を継続的に行えるようになり自立したプロダクトチームとなる
  • バンドルカード全体のミッションや目標が各チームの目標に繋がると同時に、各チームの目標が各メンバーの目標に繋がるようになる
    • そうなることでメンバーがどのように動くことを期待されているかが明確となり成果を出すための動きがしやすくなる

この開発体制で1年プロダクト開発を行った結果

この体制で1年ほどプロダクト開発を行ってきた結果ですが成功だったのではないかと思っています。
前述した期待する効果としては概ねその通りになったと感じています。
各チームが持っているKPIが何なのかが明確になり、開発チーム全体がそれを前提としたコミュニケーションができるようになりました。またチームが持つKPIが明確になったためそれぞれのチームがKPI向上に向けて自律的に行動ができるようにもなりました。
全体へ進捗を共有し合うような定例において各チームがKPIの現在地であったり向上施策や向上に至るまでのロードマップ等を共有する動きが見られるようになり全体が前進しているように感じます。

いい例として、先日バンドルカードでボーナスタウンという大きめな機能のリリースを行ったのですが、以前であればリリース後にチームは解散し、誰がその後の向上施策を行うかといったことが明確ではない状態になっていたと思いますが、現在はリリース後もシームレスに同じチームで課題の整理やロードマップが能動的に作成されリリース後にどのようにすればより使ってもらえるようになるかといったKPI向上のための動きがなされています。 kanmu.co.jp

これから改善していきたいこと

ただ全てがうまくいったということはもちろんなく、まだうまくいっていないこともあります。

期待していた効果として挙げた「バンドルカード全体のミッションや目標が各チームの目標に繋がると同時に、各メンバーの目標が各チームの目標に繋がる 」については各チームの目標をどのように各メンバーへ接続するのかを模索している状態で未だ運用に上手く乗せることができていません。
各チームが持つ目標は決められていますが、その目標に対し各メンバーがどのような形で貢献するか、そしてそれをどのように目標として置くかをどのように決めるかが難しく感じています。各チームをリードするのはPdMですがPdMは必ずしも各職種に関する深い知識を持ち合わせているわけではないのでそれぞれのメンバーの目標設定までを担当するのは難しいのではないかという課題があります。
なので現状は評価をする際には各チームのリード(主にPdM)が各職種のマネージャーへチームでの動きをレポートし、そのレポートを元に職種のマネージャーが評価を行うという形をとっています。

今後どのように目標設定を行っていくかといった部分に関しては改善していきたいと考えています。

さいごに

カンムではこのようにその時々に応じてどのようなプロダクト開発体制が最適なのかを考え、柔軟に体制変更が行える会社です。
このような変化は非常に大きいものでありこんなに早く適用し運用できるとは思っていませんでした。
ただこれがいつまでも最適な形だとは思っていなく今後もうまく行っていない点を継続的に見つけ、チューニングしていくことが必要だと思っています。カンムはそれができる組織です。
カンムはソフトウェアエンジニア、デザイナー、PdMといった職種を中心に積極的に採用を行っています。
もし少しでもご興味があればぜひカジュアル面談でお話できれば幸いです。 team.kanmu.co.jp

BCD を取り回すライブラリを Rust で書いた

エンジニアの佐野です。文字列および数値を BCD と相互変換するライブラリを Rust で書きました。

github.com

crates.io にも公開しました https://crates.io/crates/bcd-convert

BCD

BCD は Binary-coded decimal の略で日本語では二進化十進表現などとも呼ばれます。

カンムの業務で言うとクレカ決済のデータを扱っているとたびたび出現します*1。例えばカード決済のデータをやりとりする ISO8583 というプロトコルではカード番号は 0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99 のようなバイト列で表現されたりするのですが、このような処理をする際は1バイトの上位4ビットと下位4ビットに現れる数字列を分解して 4019249999999999 と解釈してカード番号を得ます*2

このライブラリを使うと BCD <-> 文字列は次のように相互変換できます。

#[test]
fn test_bcd_to_str() {
    let bcd = BcdNumber(vec![0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99]);
    let s = bcd.to_string();
    assert_eq!(s, "4019249999999999");
}

#[test]
fn test_str_to_bcd() {
    let s = "4019249999999999";
    let bcd = s.parse::<BcdNumber>().unwrap();
    assert_eq!(
        bcd,
        BcdNumber(vec![0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99])
    );
}

詳しい使い方は Docs.rsGitHub を見ていただくとして、 u64 や [u8] のような生バイト列との相互変換もサポートしています。

なぜ Rust

カンムのメイン言語は Go, TypeScript, Python なのですがたまには新しい言語を学びたくなってくるものです。その一環として自分は Go で書かれた決済システムの一部を Rust で書き換えるという個人プロジェクトを始めました*3。ゴールは Rust への書き換えと私自身の満足です。

チュートリアルを1.5周ほどやったあとに AI に手伝ってもらいながら書いたのですがまだ勘所がわからず四苦八苦している状態です。もっとうまい書き方や「らしい」書き方があるかもしれません。

おわり

*1:このような形式で送受信するケースがある

*2:これはもちろん存在しないカード番号で今後も発行されることもないカード番号です

*3:カンムでは今のところ Rust をプロダクションに導入する計画はなく、あくまでも私が自分の目標のために始めました。忙しくなったを言い訳にして頓挫する可能性もあります。