Article

使われていないソフトウェアライセンスの発見術

高田晃太郎
使われていないソフトウェアライセンスの発見術

複数の公開レポートでは、未使用・過少利用のSaaSライセンスが全体の20〜40%に達するケースが示されています。[1][2][3] 例えば、1,000名規模で年間2億円のSaaS支出がある組織なら、数千万円規模のムダが潜んでいる可能性があります。ベンダーの公開知見でも、座席の割当精度そのものより、実際の利用状況を継続的に可視化することがコスト最適化に強く相関するとされています[4]。現場では、棚卸しやスポット調査だけでは、離職・異動・休職・重複アカウント・SSO外ログインといった“見えない穴”が継続的に発生するのが常態です[5]。だからこそ、発見と回収を反復するオペレーションを、データと自動化で日常化することが、CTOやエンジニアリングリーダーに求められる設計だと考えます。

課題の規模を定量化し、見える化の土台を設計する

最初に必要なのは、理想論ではなく“いまどれだけ無駄があるか”を粗くでも定量化すること。複数の公開レポートは、ライセンス状態が短期間で変動し続けるため、継続的な利用監視を推奨しています[4][5]。ここで重要なのは、未使用を判定するためのデータ粒度と定義の一貫性です。例えば、最終ログインが60日以上ないユーザーを“未使用”とするのか、コラボレーションやAPIコールなどの機能別アクティビティを要件に含めるのかで、検出率と誤検出率は大きく変わります。実務では、ディレクトリ(ID基盤。例: Okta/Azure AD)を正とし、アプリ側の割当情報と利用ログを突き合わせる三層モデルが堅実です。IDはOktaやAzure AD、割当は各SaaS管理コンソールやSCIM(ユーザー・グループの標準プロビジョニング)、利用ログは監査・レポートAPIを使います。EメールやUPN(User Principal Name)、外部IDで正引き・逆引きを行い、ドメインエイリアスや重複アカウントを正規化してから突合します。ここで同一人物の名寄せが甘いと、オーファン(孤児)アカウントと休眠アカウントの区別ができず、回収の優先順位を誤ります。

データモデルと指標の決め方

スキーマはシンプルで構いません。identityテーブルに人の整合済みキー、entitlementテーブルにアプリとプラン、assignmentテーブルに座席割当の履歴、usageテーブルに機能別イベントを格納します。指標は、最終アクティブ日時、30/60/90日非アクティブフラグ、プラン別ARPU(平均単価)、休眠からの復帰率、回収リードタイムなどを定めます。“60日非アクティブかつ業務役割が不要”のように、テクニカル条件と人事・役割の条件を併記してルール化することで、ビジネス側の納得感が生まれます[4]。まずは粗い定義から始め、誤検出が出たらルールに例外条件を追加し、四半期ごとに再学習させる運用が現実的です。

実装の前提とセキュリティ

APIスコープは最小権限で与え、監査ログの保持期間に合わせてパイプラインをスケジュールします。SSO(シングルサインオン)外の直接ログインが許容されている場合は、アプリ側のログ取得を優先しなければ検出漏れが出ます。メール別名や来客用アカウント、サービスアカウントは除外タグを付けて、誤回収を避けます。データ処理基盤はBigQueryやSnowflakeのような列指向DWHを使うと、大量イベントでも短時間で安定して集計できます。ローカルのpandas処理よりスケール・信頼性の確保が容易で、運用負荷も下げられます。

データ取得と突合をコードで固める

Okta、Microsoft 365、Google Workspace、Slack、GitHubなど主要SaaSの標準APIと繋げます。ここでは代表的なコード例を示します。すべての例で、APIトークンやテナント情報は環境変数から読み込み、タイムアウトとリトライを備えます。

Oktaのユーザーと最終ログイン取得

import os, time, requests
from requests.adapters import HTTPAdapter, Retry

OKTA_DOMAIN = os.environ["OKTA_DOMAIN"]  # e.g., dev-xxxx.okta.com
OKTA_TOKEN = os.environ["OKTA_TOKEN"]

s = requests.Session()
retries = Retry(total=5, backoff_factor=0.3, status_forcelist=[429, 500, 502, 503, 504])
s.mount("https://", HTTPAdapter(max_retries=retries))
headers = {"Authorization": f"SSWS {OKTA_TOKEN}", "Accept": "application/json"}

users = []
url = f"https://{OKTA_DOMAIN}/api/v1/users?limit=200"
while url:
    try:
        r = s.get(url, headers=headers, timeout=15)
        r.raise_for_status()
        users.extend(r.json())
        url = None
        for link in r.links.values():
            if link.get("rel") == "next":
                url = link.get("url")
                break
    except requests.RequestException as e:
        time.sleep(1)
        raise RuntimeError(f"Okta API error: {e}")

# user profile and lastLogin are included; normalize email key
records = [{
    "email": (u.get("profile", {}).get("email") or u.get("profile", {}).get("login", "")).lower(),
    "last_login": u.get("lastLogin"),
    "status": u.get("status"),
    "id": u.get("id")
} for u in users if u.get("status") != "DEPROVISIONED"]
print(len(records))

OktaのlastLoginはSSO通過時点のログに依存します。アプリ側ログと合わせて評価する際は、この値だけで未使用判定を下さない方が安全です。

Microsoft Graphで割当ライセンスとサインイン状況

import os, requests
from requests.adapters import HTTPAdapter, Retry
from msal import ConfidentialClientApplication

TENANT_ID = os.environ["AZ_TENANT_ID"]
CLIENT_ID = os.environ["AZ_CLIENT_ID"]
CLIENT_SECRET = os.environ["AZ_CLIENT_SECRET"]

app = ConfidentialClientApplication(CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}", client_credential=CLIENT_SECRET)
scopes = ["https://graph.microsoft.com/.default"]
result = app.acquire_token_silent(scopes, account=None) or app.acquire_token_for_client(scopes=scopes)
if "access_token" not in result:
    raise RuntimeError(f"Token acquisition failed: {result}")

ts = requests.Session()
ts.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=0.3)))
headers = {"Authorization": f"Bearer {result['access_token']}", "ConsistencyLevel": "eventual"}

# signInActivity は v1.0 で取得可能(テナント設定要)
url = "https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,assignedLicenses,signInActivity&$top=999"
users = []
while url:
    r = ts.get(url, headers=headers, timeout=20)
    r.raise_for_status()
    data = r.json()
    users.extend(data.get("value", []))
    url = data.get("@odata.nextLink")

records = [{
    "email": (u.get("mail") or "").lower(),
    "last_sign_in": (u.get("signInActivity") or {}).get("lastSignInDateTime"),
    "assigned_skus": [lic.get("skuId") for lic in u.get("assignedLicenses", [])]
} for u in users]
print(len(records))

GraphのsignInActivityはテナントの監査設定やライセンスに依存します。事前に可用性を確認し、メールアドレスの別名やUPNがmailと異なる場合は、IDマッピングテーブルを用意して正規化します。

Google Admin Reportsで最終ログインを取得

import os
from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/admin.reports.audit.readonly"]
SA_PATH = os.environ["GOOGLE_SA_JSON"]
CUSTOMER_ID = os.environ["GOOGLE_CUSTOMER_ID"]

creds = service_account.Credentials.from_service_account_file(SA_PATH, scopes=SCOPES).with_subject(os.environ["GOOGLE_DELEGATED_ADMIN"])
service = build('admin', 'reports_v1', credentials=creds, cache_discovery=False)

# accounts:last_login_time を参照
resp = service.userUsageReport().get(userKey='all', date='2025-08-01', parameters='accounts:last_login_time').execute()
rows = resp.get('usageReports', [])
records = []
for r in rows:
    email = r.get('entity', {}).get('userEmail', '').lower()
    last_login = None
    for m in r.get('parameters', []):
        if m.get('name') == 'accounts:last_login_time':
            last_login = m.get('datetimeValue')
            break
    records.append({"email": email, "last_login": last_login})
print(len(records))

Google Workspaceは日次スナップショットでの収集となるため、判定窓を60日や90日にするなど、粒度に合ったルール設定が必要です。監査保持期間が短いアプリは、イベントをストレージにエクスポートして長期保存すると分析の安定性が増します[4]。

Slackの監査イベントからユーザー活動を推定

import os, time, requests

SLACK_TOKEN = os.environ["SLACK_AUDIT_TOKEN"]  # Enterprise Grid required
TEAM_ID = os.environ.get("SLACK_TEAM_ID")

s = requests.Session()
s.headers.update({"Authorization": f"Bearer {SLACK_TOKEN}"})

# 直近90日の user_login 系イベントを取得し、最終活動時刻を集計
latest = int(time.time())
oldest = latest - 90*24*3600
url = f"https://api.slack.com/audit/v1/logs?limit=500&oldest={oldest}&latest={latest}&action=user_login"
if TEAM_ID:
    url += f"&team_id={TEAM_ID}"

events = []
while url:
    r = s.get(url, timeout=20)
    r.raise_for_status()
    data = r.json()
    events.extend(data.get("entries", []))
    url = data.get("response_metadata", {}).get("next_cursor")
    url = f"https://api.slack.com/audit/v1/logs?cursor={url}" if url else None

last_active = {}
for e in events:
    user = e.get("actor", {}).get("user", {}).get("email", "").lower()
    ts = e.get("date_create")
    if user and ts:
        last_active[user] = max(last_active.get(user, 0), ts)

print(len(last_active))

Slackはプランや構成で取得できるメトリクスが異なります。企業アナリティクスや監査APIの可用性に応じて、メッセージ投稿やファイル操作といった代替シグナルも検討します。これらは現場運用に合わせて漸進的に拡張すれば十分です。

SQLで30/60/90日非アクティブを集計

-- BigQuery StandardSQL sample
WITH last_seen AS (
  SELECT
    LOWER(user_email) AS email,
    MAX(event_ts) AS last_active
  FROM `project.dataset.usage_events`
  WHERE app = 'slack'
  GROUP BY email
)
SELECT
  e.email,
  e.app,
  a.plan,
  l.last_active,
  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), l.last_active, DAY) AS inactive_days,
  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), l.last_active, DAY) >= 30 AS inactive_30,
  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), l.last_active, DAY) >= 60 AS inactive_60,
  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), l.last_active, DAY) >= 90 AS inactive_90
FROM `project.dataset.entitlements` e
LEFT JOIN `project.dataset.assignments` a USING(email, app)
LEFT JOIN last_seen l USING(email)
WHERE a.assigned = TRUE;

このレイヤーまで来ると、未使用候補の抽出は単なるクエリで再現可能になります。計算は列指向DWHで行い、アプリ側はメタデータの表示と承認フローに専念させる方が、スケールと可観測性が確保しやすいのが利点です。

検出ロジック、精度管理、そして人間の合意形成

“未使用”を技術的に検出できても、現場への通知と回収の合意を得られなければ、成果は積み上がりません。運用はルールを三段階に分けると現実的です。まずは休眠候補の抽出、次にビジネス例外のフィルタ、最後に通知から回収までのSLA(合意された対応時間)の定義。ここでは例外の設計が肝で、季節雇用や育休、監査役や顧問のように低頻度だが継続アクセスが正当なユーザーを守る必要があります。“技術シグナルだけでは決めない”という前提を最初に共有し、部門の承認者をワークフローに組み込みます。

Pythonで簡易ルールエンジンを実装してみる

from datetime import datetime, timezone, timedelta
from typing import Dict, Any

EXCEPT_ROLES = {"auditor", "advisor"}
THRESHOLD = 60  # days

def is_inactive(user: Dict[str, Any]) -> bool:
    if user.get("role") in EXCEPT_ROLES:
        return False
    if user.get("leave_status") in {"育休", "産休"}:
        return False
    last = user.get("last_active")
    if not last:
        return True  # no signal
    if isinstance(last, str):
        last = datetime.fromisoformat(last.replace("Z", "+00:00"))
    delta = datetime.now(timezone.utc) - last
    return delta >= timedelta(days=THRESHOLD)

# sample
print(is_inactive({"role":"member","leave_status":None,"last_active":"2025-06-20T10:00:00Z"}))

実運用では、例外条件は組織のポリシーに応じて拡張されます。誤検出が発生した場合は、最小限のデータポイントで説明可能なルールに還元し、監査対応の説明責任を果たせるよう記録を残します。通知は段階的に行い、初回通知の7日後にリマインド、さらに7日後に自動回収というように、人が判断する余地を残しつつも、放置しても回収される既定路線を設定します。

CSV突合で“すぐに効く”可視化を作る

import csv

with open('assignments.csv') as f1, open('usage.csv') as f2:
    a = {row['email'].lower(): row for row in csv.DictReader(f1)}
    u = {row['email'].lower(): row for row in csv.DictReader(f2)}

candidates = []
for email, row in a.items():
    inactive = row.get('inactive_60') == 'true' or not u.get(email)
    if inactive and row.get('plan') in {'enterprise','pro'}:
        candidates.append({"email": email, "plan": row['plan']})

print(f"reclaim candidates: {len(candidates)}")

理想のデータ連携を待たずとも、まずはCSVの突合で可視化し、部門別に“どれだけ眠っているか”を示すだけで行動は変わります。ここでの目的は、回収のパイロットを短期間で成立させることです。

回収の自動化、ツール選定、ROIの提示

検出の先にある要は、回収をいかに手離れよく回すかです。市販のSaaS管理ツールは、検出ルール、通知、ワークフロー、役割承認、回収実行までを一体で提供します[5]。一方で、すべてを内製化する場合は、ID基盤のグループ制御とSCIMプロビジョニング、アプリ固有APIを組み合わせて席の解除やプランダウン、アーカイブを自動化します。ツールの価値は“網羅的なコネクタ数”より、自社で規約化したルールと承認フローをどれだけロスなく表現できるかにあります[4]。PoC(小規模検証)では、直近90日での回収件数、回収から再割当までのリードタイム、回収後の復帰率といった3つのKPIで比較すると判断がしやすくなります。

ROIのラフ計算をコード化する

def roi(annual_spend: float, waste_ratio: float, recover_rate: float, tool_cost: float) -> dict:
    potential_waste = annual_spend * waste_ratio
    recovered = potential_waste * recover_rate
    net_saving = recovered - tool_cost
    roi_pct = (net_saving / tool_cost) * 100 if tool_cost else float('inf')
    return {
        "potential_waste": round(potential_waste),
        "recovered": round(recovered),
        "net_saving": round(net_saving),
        "roi_pct": round(roi_pct, 1)
    }

print(roi(annual_spend=200_000_000, waste_ratio=0.25, recover_rate=0.6, tool_cost=6_000_000))

この仮定では、年間2億円のSaaS支出、未使用・過少利用25%、回収率60%、ツール費用600万円という前提で、純額で2,400万円以上の削減という計算例になります。もちろん各値は組織ごとに異なるため、最初の四半期で自社の実測値を採り、回収率とツール費用の比で“黒字ライン”を示すのが意思決定の近道です。削減の証拠は、席の解除ログ、プラン変更の監査記録、請求額の差分という監査可能なデータで残すことを忘れないでください。

安全弁とユーザー体験の両立

自動回収は強力ですが、反感を招けば持続しません。ここでは“ソフトロック”を推奨します。具体的には、プランをいきなり解除せず、まず低コストの閲覧専用プランへダウングレードし、ユーザーが次回アクセスした際にワンクリックで元に戻せる導線を置きます。これにより業務影響を最小化しながら、休眠座席を回収できます。さらに、回収候補に入った時点でSlack DMやメールで通知し、セルフ申告の例外申請フォームを同報します。“何も言わなければ回収される、必要なら簡単に戻る”という設計こそ、現実に回る仕組みです。

運用のパフォーマンスと信頼性

データパイプラインは1日1回の全量でも十分ですが、入退社イベントは即時に反映させたいところです。WebhookやSCIMのイベントハンドラで、入社時は必要アプリを自動割当、退社時は全解除を直ちに実行すれば、そもそも“未使用”が生まれにくくなります。パイプラインのSLO(目標提供水準)は、前日分のイベントを業務開始前に評価して通知まで完了、障害時は12時間以内のリカバリを目標とすると現場負荷が下がります。代表的な構成でも、10〜20の主要SaaS連携で収集〜集計〜通知の一連処理は数十分程度に収まり、再試行やエスカレーションの設計を組み合わせれば安定運用が実現しやすくなります。

まとめ:発見術を“日常の仕組み”にする

未使用ライセンスの発見は、単発の棚卸しでは持続しません。ID、割当、利用ログという三層を日次で突き合わせ、例外を明文化してワークフローに載せる。これを続ければ、四半期の回収効果は安定し、翌年度の契約更新交渉でも強い立場に立てます。どこから始めるか迷うなら、まずは一つのハイコストSaaSに絞って、60日非アクティブの候補抽出、部門承認、ソフトロックでの回収という小さなループを回してください。手応えが出たら、コネクタを増やし、通知と回収を段階的に自動化します。次の更新までに何席を戻せると、あなたは胸を張って言えるでしょうか。今日の小さな可視化が、数千万円のムダを止める最短の一歩になります。

参考文献

  1. CFO Dive. SaaS license wastage ranked as top IT spend challenge. https://www.cfodive.com/news/saas-license-wastage-ranked-as-top-it-spend-challenge/708580/
  2. TechRepublic. Almost half of all installed software is rarely or never used. https://www.techrepublic.com/article/half-software-licenses-unused/
  3. E3 Magazine. Employees use only half of the software licenses provided. https://e3mag.com/en/employees-use-only-half-of-the-software-licenses-provided/
  4. Torii. Optimize license cost: why usage visibility matters for SaaS license optimization. https://www.toriihq.com/blog/optimize-license-cost
  5. WalkMe. SaaS license management: why a dynamic approach is needed to track usage changes. https://www.walkme.com/blog/saas-license-management/