メインコンテンツへスキップ

共有アカウントの認証情報を安全に配るAccount Managerの設計

tomokisuntomokisun
共有アカウントの認証情報を安全に配るAccount Managerの設計

わたしたちは、社内で共有しているサービスアカウントの認証情報を安全に発行・配布・監査するための社内ツール Account Manager を開発しました。

この記事では、個々の実装の詳細を逐一なぞるのではなく、なぜこの構成にしたのか、どこにどんな責務を置いたのか、というアーキテクチャと設計判断にフォーカスして解説します。認証、状態管理、外部サービス連携、監査ログといった、認証情報を扱うアプリに共通する設計課題に対して、わたしたちがどう答えを出したのかを共有できればと思います。

何を解決するアプリか

複数の媒体やSaaSで使う共有アカウントを運用していると、次のような課題が生まれます。

  • パスワードをSlackやスプレッドシートに平文で貼ってしまい、どこに漏れているか分からなくなる
  • 誰がいつそのアカウントを取得し、何に使ったのかが追跡できない
  • 「このアカウントのパスワードを見たい」という申請とその承認が、口頭やDMで属人的に流れてしまう

Account Managerは、これらを次の方針で解決します。

  • パスワード本体はアプリのDBに置かない。1Passwordに保管し、DBには参照IDと業務状態だけを持つ
  • 認証情報のライフサイクル(生成 → 取得 → アカウント作成 → 閲覧申請 → 監査)をすべてアプリ上の状態遷移として管理する
  • 閲覧したいパスワードは、1Passwordの「1時間・1回限り」の共有リンクとしてSlack DMに届ける

技術スタック

カテゴリ技術
フレームワークNext.js 16 (App Router, Server Actions)
UIReact 19 + Tailwind CSS
言語TypeScript 5(strict)
ランタイムBun
DBNeon PostgreSQL
ORMDrizzle ORM + Drizzle Kit
認証Slack「Sign in with Slack」(OIDC) + jose によるJWT
シークレット1Password Service Account SDK
バリデーションZod
テストVitest

全体アーキテクチャ

Account Managerは、3つの外部サービスを役割ごとに使い分ける構成になっています。

                ┌──────────────────────────────┐
                │      Account Manager          │
   ブラウザ ──▶ │  (Next.js / Server Actions)   │
                └───────┬───────┬───────┬───────┘
                        │       │       │
              認証/認可  │       │ 状態  │ シークレット
                        ▼       ▼       ▼
                   ┌────────┐ ┌──────┐ ┌───────────┐
                   │ Slack  │ │ Neon │ │ 1Password │
                   │ OIDC/  │ │  PG  │ │  (Vault)  │
                   │  Bot   │ │      │ │           │
                   └────────┘ └──────┘ └───────────┘

設計の出発点にある考え方は 「責務ごとに信頼できる場所を1つに決める」 ことです。

  • 正の状態管理はNeon PostgreSQLに集約する
  • パスワード本体は1Passwordにだけ置く
  • 本人性・所属の検証はSlackに委ねる

DBにはパスワードを保存せず、onepassword_item_idonepassword_vault_id という参照だけを持ちます。これにより、万一DBが漏洩しても、そこからパスワードそのものが流出することはありません。

認証・認可の設計

Slack「Sign in with Slack」によるワークスペース制限

ログインはSlackのOIDCに統一しています。社内ツールである以上、「特定のSlackワークスペースのメンバーだけが入れる」ことが最重要要件でした。これを多層で担保しています。

  1. 認可リクエスト時に team パラメータへ許可ワークスペースのTeam IDを渡し、Slack側でログインできるワークスペースを絞る
  2. コールバックで受け取ったIDトークンを検証し、team_id クレームが SLACK_ALLOWED_TEAM_ID と一致するかをアプリ側でも確認する
const { payload } = await jwtVerify(token.id_token, slackJwks, {
  issuer: 'https://slack.com',
  audience: requireEnv('SLACK_CLIENT_ID')
});
 
if (payload.nonce !== expectedNonce) {
  return forbidden();
}
if (payload['https://slack.com/team_id'] !== requireEnv('SLACK_ALLOWED_TEAM_ID')) {
  return forbidden();
}

CSRFとリプレイ対策として、statenonce を発行してhttpOnly Cookieに保存し、コールバックで突き合わせます。Slackの設定だけに頼らず、アプリ側でも必ず検証するのは、**「外部サービスの設定は変わりうる」**という前提に立つためです。認可境界をアプリのコードとして明示しておくことで、テストでも守れるようになります。

ロールはJWTに入れない

ログイン成功後は、jose で署名したJWTをセッションCookieとして発行します。ここで意図的に選んだ設計が 「ロール(user / admin)をJWTに入れない」 ことです。

JWTのペイロードは userId / slackUserId / slackTeamId の最小限に留め、ロールはリクエストごとにDBから引きます

// JWTには最小限の識別子しか入れない
const token = await signSession({ userId, slackUserId, slackTeamId });

ロールをトークンに焼き込むと、ユーザーを管理者に昇格・降格してもトークンの有効期限(7日)が切れるまで反映されません。DBから都度引く方式なら権限変更が即座に効きます。「権限はその瞬間の事実であるべき」という考えからの判断です。

トレードオフはリクエストごとのDBアクセスですが、Reactのリクエストスコープの cache() で同一リクエスト内の重複読み取りをまとめることで、実質的なコストを抑えています。

認可の3階層とMiddleware

認可は責務を分けて3階層で表現しています。

関数役割
getCurrentUser()Cookieを検証し、DBからユーザー(ロール含む)を返す。cache() でメモ化
requireUser()未ログインならログインフローへリダイレクト
requireAdmin()管理者でなければトップへリダイレクト

加えてNext.jsのMiddlewareで /admin/* 配下のJWT署名を入口で検証し、無効なら認証フローへ送り返します。**「Middlewareで荒く弾き、Server Component / Server Actionで細かく判定する」**という二段構えです。

認証情報の取得 ― 1ステートメントの原子的取得

このアプリでもっとも設計を悩んだのが「在庫から1件の認証情報を取得する」処理です。複数人が同時にボタンを押したときに、同じ認証情報を2人に渡してしまうことだけは避けなければなりません。

採用したのは、PostgreSQLの FOR UPDATE SKIP LOCKED を使い、SELECTとUPDATEを1つのステートメントで完結させる方法です。

WITH picked AS (
  SELECT id
  FROM credentials
  WHERE status = 'unused'
    AND ($1::text IS NULL OR domain = $1)
  ORDER BY created_at ASC
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE credentials AS c
SET status = 'in_use',
    acquired_by_user_id = $2,
    acquired_at = now(),
    updated_at = now()
FROM picked
WHERE c.id = picked.id
RETURNING c.*;
  • FOR UPDATE で選んだ行をロックし、同じ行を他のトランザクションが取得できないようにする
  • SKIP LOCKED で他がロック中の行は飛ばして次の未使用行を掴む
  • SELECTとUPDATEが分かれていないので、「選んでから更新するまでの隙間」が存在しない

アプリ側でロックを取って、確認して、更新して……というリトライループを書くと、競合時の挙動が複雑になりがちです。データベースが本来得意とする排他制御をそのまま使うことで、アプリのロジックを単純に保ったまま正しさを担保できました。返却が0件なら在庫切れ、というシンプルな分岐になります(在庫切れ時はその場で1Passwordに新規生成してフォールバックします)。

失敗したら在庫に戻す ― 補償トランザクション

認証情報を in_use にした後、1Passwordの共有リンク発行やSlack DM送信に失敗することがあります。ここで何もしないと、「取得済みなのに誰の手にも渡っていない」幽霊在庫が生まれてしまいます。

そこで、後続処理を try/catch で囲み、失敗したら状態を unused に巻き戻す補償トランザクションを入れています。

try {
  const shareLink = await createShareLink({ /* ... */ });
  await postSlackDm({ slackUserId, text: `...\n${shareLink}` });
  // 成功イベントを記録
} catch (error) {
  await db
    .update(credentials)
    .set({ status: 'unused', acquiredByUserId: null, acquiredAt: null /* ... */ })
    .where(
      and(
        eq(credentials.id, credential.id),
        eq(credentials.acquiredByUserId, input.userId) // 自分が取得した行だけ戻す
      )
    );
  await recordCredentialEvent({
    credentialId: credential.id,
    eventType: 'acquire_rolled_back',
    metadata: { rollbackReason: /* ... */ }
  });
  throw error;
}

巻き戻しの WHERE 句に acquired_by_user_id = 自分 を必ず入れているのがポイントです。これにより、万一同時実行が絡んでも**「自分が取得した行だけ」**を安全に戻せます。そして巻き戻し自体も acquire_rolled_back イベントとして監査ログに残します。失敗もまた記録すべき事実だからです。

シークレットの扱い ― DBには参照だけ

1Passwordとの連携は、src/lib/onepassword.ts という薄いラッパーに閉じ込めています。アプリの他の場所は、パスワード本体を一切知りません。

特に重要なのが、パスワードをユーザーに渡す方法です。平文を表示したりDMに貼ったりするのではなく、1Passwordの共有リンクを使います。

return op.items.shares.create(item, policy, {
  expireAfter: sdk.ItemShareDuration.OneHour, // 1時間で失効
  oneTimeOnly: true                           // 1回見たら無効
});

1時間で失効 × 1回限り という制約により、リンクが転送・スクショされても被害を最小化できます。アプリはこのリンクをSlack DMで本人にだけ届けます。シークレットの受け渡しをSlackとブラウザの外、つまり1Passwordの仕組みに乗せることで、アプリが秘密を保持する時間を限りなく短くしています。

Slack連携 ― 承認フローと冪等性

署名検証で入口を守る

Slackからのインタラクション(ボタン押下)はWebhookとして届きます。なりすましを防ぐため、Slackの署名を検証します。

  • タイムスタンプが5分以上古いリクエストはリプレイ攻撃とみなして拒否
  • HMAC-SHA256で計算した署名を timingSafeEqual定数時間比較し、タイミング攻撃を防ぐ

セキュリティ上重要なこの関数は、ヘッダ欠落・期限切れ・改竄・長さ不一致といった境界ケースをテストで網羅しています。

integration_receiptsによる二重処理防止

Slackは200応答を受け取れないとWebhookを再送します。承認ボタンの再送をそのまま処理すると、共有リンクが二重発行されるおそれがあります。

これを防ぐため、integration_receipts テーブルに受領記録を1行だけ挿入する冪等化を入れています。

const externalId = `${teamId}:${userId}:${actionId}:${value}:${actionTs}`;
const [receipt] = await db
  .insert(integrationReceipts)
  .values({ source: 'slack_interaction', externalId, payloadHash })
  .onConflictDoNothing() // 既に処理済みなら何もしない
  .returning();
 
if (!receipt) {
  return NextResponse.json({ ok: true, text: '処理済みです。' });
}
// 初回だけ本処理へ進む

external_id をペイロードから一意に組み立て、ユニーク制約 + onConflictDoNothing で**「同じイベントは一度しか処理しない」**ことをDBレベルで保証します。冪等性をアプリのフラグ管理ではなくDB制約に委ねるのは、原子的取得と同じ思想です。

閲覧申請の承認ワークフロー

すでにアカウントが作成済みの認証情報について、取得者以外がパスワードを見たいときは申請 → 承認のフローを通します。

pending ──承認──▶ approved ──共有リンク送信──▶ fulfilled
   │
   ├──却下──▶ rejected
   └──取消──▶ canceled

設計上の肝は、「承認」と「履行(fulfillment)」を別の状態として分けていることです。

  1. 管理者がSlackのボタンで承認する → approved
  2. 1Passwordの共有リンクを生成し、申請者へSlack DMで届ける
  3. 送信まで完了したら → fulfilled

もしDM送信に失敗しても、状態は approved に留まります。承認という意思決定の事実は確定済みなので、配達の失敗で意思決定まで巻き戻してしまわないようにしています。これにより再送やリカバリの余地が残ります。

申請の重複は、DBの部分ユニークインデックスで防ぎます。

unique (credential_id, requester_user_id) where status = 'pending'

「同じ人が同じ認証情報に対してpendingの申請を2つ持てない」という業務ルールを、アプリのチェックではなくDB制約として表現しているのが特徴です。

監査ログ ― 追記専用のイベント記録

このアプリでは、状態を変えるあらゆる操作を credential_events テーブルに**追記専用(append-only)**で記録します。

await recordCredentialEvent({
  credentialId,
  actorUserId,
  eventType: 'acquired',
  metadata: { emailAddress }
});

イベントは更新も削除もしません。generated / imported / acquired / shared / account_created / access_requested / access_approved / acquire_rolled_back ……といった種別で、何が起きたかを時系列に積み上げます。

設計上のポイントは次のとおりです。

  • 改竄できない監査証跡:更新・削除がないので、ログそのものが事実の記録になる
  • 柔軟なmetadata:詳細はJSONBに持ち、検索対象になる主要項目だけ通常カラムに昇格させる
  • actor / credentialはnull許容:システム起因のイベントや、ユーザー削除後も証跡を残せる

外部キーを cascade ではなく set null 中心にしているのも、ユーザーが消えても監査ログは消えないようにするためです。「現在の状態」と「起きたことの履歴」を分けて持つことで、台帳としての信頼性を確保しています。

データモデルと状態機械

正の状態はNeon PostgreSQLに集約し、状態の正しさはDB制約アプリの検証の両輪で守ります。

credentials の状態遷移はこうです。

unused ──取得──▶ in_use ──無効化──▶ disabled
   ▲                                   │
   └──────────── 管理者が復旧 ──────────┘

ここで効いているのがCHECK制約です。たとえば、

  • status = 'unused' なら取得者・取得日時はすべてnullでなければならない
  • status = 'in_use' なら取得情報かアカウント作成日時のどちらかが必ず埋まっている
  • メールアドレスは常に小文字で保存する(大文字小文字を無視した一意性)

「ありえない状態」をアプリのバグで作り込めないよう、最後の砦をDBに置く設計です。アカウント作成は状態を増やさず、in_use のまま account_created_at を埋める扱いにしています。これは「作成済み」が独立した状態ではなく、in_use に付随する事実だと捉えているためです。

アプリの層構造

コードはNext.js App Routerのルートグループと、lib 配下の責務分割で整理しています。

src/lib/
├── auth.ts / authorization.ts        # 認証・認可
├── credentials/
│   ├── service.ts                    # 業務ロジック(生成・取得・無効化…)
│   ├── queries.ts                    # 読み取り専用クエリ
│   ├── actions.ts                    # Server Actions(フォーム → service)
│   └── validation.ts                 # Zodスキーマ
├── access-requests/service.ts        # 承認ワークフロー
├── credential-acquisition.ts         # 原子的取得SQL
├── credential-events.ts              # イベント記録
├── onepassword.ts / slack.ts         # 外部連携の薄いラッパー
└── env.ts                            # 環境変数の検証

設計の指針は 「副作用の境界をはっきりさせる」 ことです。

  • Server Actions をミューテーションの唯一の入口にする。入力をZodで検証し、service を呼び、キャッシュを再検証する
  • service は状態を持たず、必要なコンテキスト(ユーザーIDなど)を引数で受け取る。だからテストしやすい
  • 読み取り(queries)と書き込み(service / actions)を分けることで、どこで状態が変わるのかを追いやすくする

UIはAtomic Design(atoms / molecules / organisms)で構成し、フォームは「Server Actionを呼ぶorganism」として実装しています。

環境変数はfail-fastで検証する

外部サービスへの依存が多いので、設定漏れは起動時・利用時の早い段階で気づきたい。そこで requireEnv で必須の環境変数が無ければ即座に例外を投げます。

export function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`${name} is not configured`);
  }
  return value;
}

ユーザー操作の途中で謎のエラーになるより、境界で早く落ちるほうが運用は楽になります。

キャッシュ戦略

管理ダッシュボードの集計(ユーザー数・無効化された認証情報・承認待ち件数)は、毎リクエストで重いJOINを走らせたくありません。そこでNext.jsのキャッシュタグを使い、adminStats というタグでメモ化しています。

状態を変える操作(ログインによるユーザー増加、認証情報の無効化、申請の状態遷移など)の後に revalidateTag で明示的に無効化します。あえて細粒度のタグに分けず、単一の粗いタグ + 短い再検証期間(60秒) という割り切りで、無効化ロジックを単純に保っています。「整合性のために複雑にしすぎない」というバランスの取り方です。

テスト方針

セキュリティと正しさが要のアプリなので、テストは実装の隣に co-locate(foo.tsfoo.test.ts)し、Vitestでバックエンド(node)とコンポーネント(jsdom)を別プロジェクトとして実行しています。

特に署名検証や認証フローのようなセキュリティクリティカルな関数は境界値を網羅的にテストしています。トークンの改竄検知、鍵のローテーション、署名の長さ不一致による回避の防止など、「壊れ方」を一つずつ潰しているのが特徴です。

設計判断のまとめ

最後に、この記事で紹介した主要な設計判断とその狙いを一覧にまとめます。

設計判断狙いトレードオフ
パスワードはDBに置かず1PasswordへDB漏洩時も秘密が流出しない外部サービス依存が増える
共有は1時間・1回限りのリンク転送・流出時の被害を最小化一手間増える/期限切れの再発行が必要
FOR UPDATE SKIP LOCKED の原子的取得同時取得の競合をDBで防ぐPostgreSQL固有の構文に依存
失敗時の補償トランザクション幽霊在庫を作らないエラーハンドリングが複雑化
integration_receipts による冪等化Slack再送の二重処理を防ぐテーブルと制約が増える
追記専用イベントログ改竄できない監査証跡ストレージが増え続ける
ロールをJWTに入れない権限変更が即時反映されるリクエストごとにDB参照(cacheで緩和)
承認と履行を別状態に分離配達失敗で意思決定を巻き戻さない状態が一つ増える

通底しているのは、**「正しさの保証をできるだけデータベースに寄せる」**という考え方です。原子的取得・冪等化・CHECK制約・部分ユニークインデックスといった仕組みで、アプリのコードを単純に保ったまま、競合や二重処理といった分散システム特有の難しさに対処しています。

認証情報という繊細なデータを扱うツールだからこそ、セキュリティ・信頼性・監査可能性を、派手な仕組みではなく地道な設計判断の積み重ねで担保することを大切にしました。同じように社内の認証情報管理に悩んでいるチームの参考になれば幸いです。