AIエージェント
AIエージェントを、人と同じ「テナントのメンバー」として参加させる仕組み。今後どの SaaS にもエージェントが参加する前提で、Lase の共通機能として持つ。
考え方
- エージェントは
usersの1行で表す。 名前を持って行動する主体という点で人と同じなので、別テーブルにしない。アプリ側の担当者・メンション・更新履歴などの仕組みがそのまま使える。 - 人との違いは
users.typeだけで持つ。HUMAN(既定)とAGENT(UserType)。users は複数のテナントにまたがるアカウントなので、ここには主体そのものの種類だけを持ち、管理者・ゲストのような権限の段階はテナントへの所属(tenant_users)に持つ。 - エージェントはログインしない。 パスワードもメールアドレスも持たず、メールも届かない。認証はエージェントに紐づくトークン(下記)だけで行う。
- エージェントは1つのテナントだけに属する。 作成時に
tenant_usersも一緒に作り、他テナントへは参加させない。 - 責任者・説明などの付加情報は持たない。作成・停止は管理者が行い、誰が作ったかは監査ログで追う。
データ
| テーブル | カラム | 内容 |
|---|---|---|
users | type | HUMAN / AGENT(UserType)。既定は HUMAN |
users | email | エージェントでは NULL(人は従来どおり必須。入力の検証はアプリ・API 側で行う) |
mcp_service_tokens | user_id | トークンの持ち主のエージェント。必須 |
ログインとメールの遮断
User::isAgent() で判定し、次の入口でエージェントを拒否する。
- パスワードログイン(
Auth::attemptの条件にtype = HUMANを含める) - テナントへのトークンログイン(
TenantLoginTokenService::canLogin) - 管理者の代理ログイン(
impersonateByToken)
メールは User::routeNotificationForMail() がエージェントでは null を返すため、通知を送っても届かない。
メンバー画面との分離
エージェントの操作は AgentController に限る。テナントのメンバー画面(TenantUserController)の一覧にはエージェントを出さず、TenantUserPolicy の閲覧・状態の変更・削除でも見つからない扱い(404)にする。トークンを残したまま所属だけを消したりできないようにするため。管理画面(Admin\TenantUserController)には従来どおり表示する。
エージェントの権限も、人と同じ役割(tenant_roles)で決める。役割は人と同じく1つ(役割なしもある)。役割の変更は AgentController::update(tenantRoleId)で行い、人の役割の変更と同じく TenantUserPolicy::changeRole と、付ける役割の TenantRolePolicy::assign で確認する(エージェントに許すのは changeRole だけで、閲覧・状態の変更・削除は 404 のまま)。
エージェントにはオーナー(全権限 *)の役割を付けられない。 トークンを持つエージェントが全権限を持たないようにするため。tenantRoleId にオーナーの役割を指定すると 422 になり、画面の選択肢(GET /api/tenant/agents の roles)にも出さない。
作成・変更・削除(AgentService)
| 操作 | 内容 |
|---|---|
create(Tenant, name) | エージェントの users と tenant_users(ACTIVE)を1トランザクションで作り、既定の役割を付ける(下記)。上限を超える場合は作らない |
rename(TenantUser, name) | 名前を変える |
setStatus(TenantUser, TenantUserStatus) | tenant_users.status を ACTIVE / SUSPENDED に切り替える。停止中はトークンでの認証を拒否する |
assignRole(TenantUser, ?tenantRoleId) | 役割を付け替える(null なら外す)。オーナーの役割の場合は ValidationException。権限のキャッシュも捨てる |
assignableRoles() | エージェントに付けられる役割(オーナーの役割を除く) |
delete(TenantUser) | トークンを無効化し、tenant_users と users を論理削除する |
上限(料金)
人とは別の料金体系にするため、エージェントの数は Subscription のクオータで数える。config('lase.agents.quota_metric')(LASE_AGENT_QUOTA_METRIC)に metric 名を設定すると、作成済みのエージェント数 < 恒久クオータ + 契約クオータ のときだけ作成できる。未設定(既定)なら上限なし。料金が決まるまでは未設定で運用する。
既定の役割
config('lase.agents.default_role_code')(LASE_AGENT_DEFAULT_ROLE_CODE)に役割の code(tenant_roles.code)を設定すると、作ったエージェントにその役割を付ける。未設定(既定)なら役割を付けない(権限を何も持たない)。設定した code の役割が無い場合と、オーナーの役割の場合は、エージェントを作らずに LogicException を投げる。
アプリは、エージェント用の役割をマイグレーションで code 付きで登録し、ここに設定する。
認証(エージェントのトークン)
MCP の Service Token は、必ずエージェントに紐づく「エージェントのトークン」とした。
- 認証すると、トークンの持ち主のエージェントとして動く。
McpPrincipalContextはtype = agent、userIdにエージェントの ID が入る。 - 次のどれかに当てはまると拒否する: トークンの失効・期限切れ・IP 制限、エージェントが
AGENTでない、ユーザーまたは所属(tenant_users)が ACTIVE でない、所属のテナントがトークンのテナントと違う。 - 認証後は
Auth::setUser()でエージェントを現在のユーザーにする。アプリ側は人のリクエストと同じくauth()->user()で主体を取れる。 - OAuth(人の代理)は従来どおり
type = user。clientIdでどのアプリ経由かが分かる。
テナント API
権限はどちらも mcp.service-tokens:manage。
| メソッド | パス | 内容 |
|---|---|---|
| GET | /api/tenant/agents | エージェントの一覧(役割 role・トークンを含む)。roles に付けられる役割を、mcp に組織が MCP 連携を使えるか(available・reason・message)を返す |
| POST | /api/tenant/agents | 作成(name) |
| PUT | /api/tenant/agents/{agent} | 名前・状態・役割(name, status, tenantRoleId。null なら役割を外す)の変更。役割の変更には user:write も要る |
| DELETE | /api/tenant/agents/{agent} | 削除 |
| POST | /api/tenant/agents/{agent}/tokens | トークンの発行(name, allowedIps, expiresAt)。平文は応答で一度だけ返す。範囲は絞らず、エージェントの役割の権限で決まる。組織が MCP 連携を使えなければ 403(code: MCP_UNAVAILABLE) |
| POST | /api/tenant/mcp-service-tokens/{token}/rotate | ローテーション |
| DELETE | /api/tenant/mcp-service-tokens/{token} | 無効化 |
画面は組織の設定(createSettingsRoutes())の「エージェント」(settings.agents)。エージェントの作成・停止・削除・役割の付け外しと、トークンの発行・ローテーション・無効化を行う。組織が MCP 連携を使えないときは理由を示し、トークンの発行とローテーションを止める。アプリが userTabs で足したタブ(所属など)は、エージェントごとのボタンから開いて編集する。
エージェントの状態と役割は、この API だけで変える(メンバーの PUT /api/tenant/tenant-users/{id} はエージェントには 404 を返す)。