← すべての記事
セキュリティとガバナンス IT / CIO · · 著者 ObjectStack Team

誰が顧客レコードを開いたか、AI エージェントも含めて証明できますか?レコード閲覧監査と、閲覧行が決して記録しない 4 つのこと

@objectstack/cli 18.3.0 で、人の REST 読み取りとエージェントの MCP get_record はそれぞれ sys_audit_log に read 行を 1 つ残し、一覧・未登録オブジェクト・拒否された読み取りは何も残さなかった。行が持つもの、決して持たない 4 つのこと。

誰が顧客レコードを開いたか、AI エージェントも含めて証明できますか?レコード閲覧監査と、閲覧行が決して記録しない 4 つのこと 誰が顧客レコードを開いたか、AI エージェントも含めて証明できますか?レコード閲覧監査と、閲覧行が決して記録しない 4 つのこと
  • 監査証跡
  • レコード閲覧監査
  • AI エージェント
  • コンプライアンス

2 行。「今日の午後、Ada Lovelace と Linus Torvalds の連絡先レコードを開いたのは誰か」という問いへの答えはそれで全部です。@objectstack/cli 18.3.0 で動く CRM の台帳は、こう返します。

created_atactionuser_idobject_namerecord_idold_valuenew_valueip_address
02:31:27.453ZreadVPUrZn…(営業担当の Dana、REST 経由)crm_contactqKPW3T…(Ada)nullnullnull
02:31:27.490ZreadHQ6GfW…(エージェントの API キー、MCP 経由)crm_contactAa5lJE…(Linus)nullnullnull

5 回の読み取りが、この 2 行を生みました。人が REST API で連絡先を 1 件開き、AI エージェントが自分の API キーで MCP get_record により別の 1 件を取得し、同じ人が全連絡先を一覧し、取引先を 1 件開き(このデプロイがオプトインしていないオブジェクト)、ポジションを持たないユーザーが連絡先を開こうとして 403 を受けた。一覧と取引先と拒否は、何も残しませんでした。これが本稿が端から端まで測定して擁護する主張です。ObjectStack のレコード閲覧監査は、人とエージェントを区別なく、誰がどのレコードをいつ開いたかを記録し、その範囲は「記録しないこと」が「記録すること」と同じくらい明瞭に読めるほど狭く宣言されている。持ち帰る価値のある概念は発見される境界ではなく、宣言された境界です。端が書き下ろされたコンプライアンス機能は、監査の最中に端を見つける機能に勝ります。

権限レイヤーが答えられない問い

どの権限レイヤーもこのレコードを誰が見てよいかに答えます。オブジェクト権限、行レベルセキュリティ、フィールドマスキング。規制業界のレビューは、その次の問いから始まります。ObjectStack 自身のレコード閲覧監査のドキュメントは 2 文目でこう述べます。このページは「その後に来る問い、そして規制業界のあらゆるレビューが冒頭で発する問いに答える:実際に誰がそれを開いたのか、いつか?」(18.3.0 タグの content/docs/permissions/record-view-auditing.mdx 8–9 行目)。

このブログの EU AI Act の記事は、監査人が求めるのはポリシー文書ではなくひとつのアクションの記録だと論じました。権限境界の記事はチェックがどこで走るかの話でした。どちらも読み取りは扱っていません。読み取りは何も変えない唯一のアクションであり、気づくために作られたものがなければ何も残しません。ObjectStack でそれにあたるのが sys_audit_log の read アクションで、@objectstack/plugin-audit が afterFind フックから書き込みます。同じ台帳にはライフサイクルフックからの create / update / delete 行と、services.audit スロット経由の login / logout 行も載ります。閲覧行は 3 つ目の面であり、唯一デフォルトでオフのものです。

有効化はコンストラクタ引数であり、それ以外にない

監査対象の集合は、プラグインがインストールされる場所で一度だけ与えます。CLI で起動するアプリでは、それはスタックの plugins 配列です。以下で使う CRM の例に必要だった変更は 2 つ。pnpm add @objectstack/[email protected] と、objectstack.config.ts へのこれです。

import { AuditPlugin } from '@objectstack/plugin-audit';

export default defineStack({
  // …manifest, objects, views, permissions…
  plugins: [
    new AuditPlugin({ readAudit: { objects: ['crm_contact'] } }),
  ],
});

readAudit が受け取るキーはちょうど 3 つ。objects(オブジェクト単位の閉じたオプトイン、既定は空)、maxBatchSize(既定 50)、flushIntervalMs(既定 2000)。空のリストは「フックをまったく登録しない」ので、何もオプトインしないデプロイは読み取りパスで何も払いません。AuditPluginOptions はもう 1 つのキー getLocale も宣言しています。アクティビティ要約を書く言語を決めるホスト側のロケールリゾルバーで、何を監査するかは変えません。

あえて存在しないものの方が、存在するものより重要です。ドキュメントは太字で述べます。「オブジェクトメタデータのキーも、環境変数も存在しない。」enable.auditReads は存在せず、スタック設定の audit: ブロックがオブジェクトを名指すこともなく、requires: ['audit'] はプラグインを起動要件にするものの「オプションなしでプラグインを構築し、オブジェクトを名指す場所をどこにも与えない」。挙げられている理由こそが、本稿を設定メモではなくガバナンスの記事にしているものです。宣言可能なキーは「このプラグインを一度もインストールしないデプロイのオブジェクトにも設定でき、監査済みと読めるのに何も記録しないメタデータを生む。コンプライアンスの面では、レビュアーがカバレッジと取り違える宣言は、機能の不在より悪い。」

この形は CLI 上で目に見える結果をひとつ持ちます。os serve と os dev は、あなたの plugins を走査する前に自前のオプションなし AuditPlugin を自動登録し、同名の後続登録は先行登録を置き換えます。測定した起動ログには、オプトインが効いたことを証明する行があります。

WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v1.0.0)
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
started; the earlier one is discarded without ever running init().

ドキュメントはこの行が「オプトインが機能している証拠であり、設定ミスではない」と明言します。そしてこれが起動時に得られる唯一の信号でもあります。18.3.0 でも「record-view auditing installed on N object(s)」の行は info レベルで書かれ、dev サーバーの既定 warn より下なので、既定の起動で見えるのは、起動バナーの「Boot diagnostics」見出しの下に並ぶ superseded の警告です。

5 回の読み取り、2 行

アプリは ObjectStack リポジトリのタグ @objectstack/[email protected] の examples/app-crm で、@objectstack/* の依存をすべて公開済み 18.3.0 パッケージに固定し、上の plugins エントリを加えたものです。objectstack dev --compile --no-watch で起動し、古い dist/objectstack.json を配信するのではなく、この起動でアーティファクトをコンパイルさせました。管理 API で 3 つのアカウントを作成。sales_rep ポジションを持つ人 Dana、AI エージェントの API キーを発行する先の識別子 agent-svc(同じく sales_rep)、そしてポジションをまったく持たない nobody。例の権限セットで sales_rep ポジションは crm_contact: { allowRead: true, … } を持つので、Dana とエージェントは連絡先を読めて nobody は読めません。

読み取りの前、台帳クエリは空ページを返します。

curl -s -b admin.cookies 'http://localhost:49880/api/v1/data/sys_audit_log?filter={"action":"read"}'
{ "object": "sys_audit_log", "records": [], "total": 0, "hasMore": false }

そして 5 回の読み取り、順番に。

#誰読み取り応答行?
ADana、サインイン済みGET /api/v1/data/crm_contact/qKPW3T…200、Ada のレコードあり
Bエージェント、x-api-key: osk_…MCP tools/call get_record { objectName: "crm_contact", recordId: "Aa5lJE…" }200、Linus のレコードあり
CDanaGET /api/v1/data/crm_contact200、3 レコードなし
DDanaGET /api/v1/data/crm_account/rx99ls…200、Acme のレコードなし
EnobodyGET /api/v1/data/crm_contact/qKPW3T…403 PERMISSION_DENIEDなし

エージェントの呼び出しは、アプリの /api/v1/mcp エンドポイントに対するごく普通の MCP リクエストで、キーだけで認証されます。

curl -s -X POST http://localhost:49880/api/v1/mcp -H "x-api-key: $KEY" \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_record",
       "arguments":{"objectName":"crm_contact","recordId":"Aa5lJEWl9nP1Hbuq"}}}'
{ "result": { "content": [ { "type": "text",
  "text": "{ \"id\": \"Aa5lJEWl9nP1Hbuq\", …, \"first_name\": \"Linus\", \"last_name\": \"Torvalds\", \"email\": \"[email protected]\", … }" } ] },
  "jsonrpc": "2.0", "id": 3 }

5 秒後、2 000 ms のフラッシュ間隔を過ぎてから管理者として同じ台帳クエリを投げると、本稿冒頭のちょうど 2 行、"total": 2 が返ります。

ObjectStack 18.3.0 での 5 回の読み取りと、そのうちどれが sys_audit_log に載るか。人の REST 詳細読み取りとエージェントの MCP get_record はそれぞれ read 行を 1 つ生み、一覧読み取り、readAudit.objects 外のオブジェクトの詳細読み取り、拒否された読み取りは何も生まない。

これらを振り分けるルールは 2 つの条件で、どちらもドキュメントにあり、どちらも測定結果に見えています。読み取りが記録されるのは「ちょうど 1 レコードを実体化した」かつ「述語が主キーを固定した」ときです。GET /data/:object/:id はエンジンに findOne(object, { where: { id } }) として届きます。MCP の get_record ツールも同様で、HTTP トランスポートでは同じデータプロトコル、同じ getData パスで解決されます。読み取り C は第 1 条件を満たしません(配列は「決して詳細閲覧ではないので、find が該当することはない」)。読み取り D はルールに拒まれたのではなく、crm_account が単に readAudit.objects に入っておらず、フックは名指されたオブジェクトにしか登録されないからです。読み取り E はフックに届いていません。そのリクエストのサーバーログには Access denied: operation 'findOne' on object 'crm_contact' is not permitted for positions [org_member, everyone] とあり、「afterFind フックには成功した読み取りしか届かない」のです。

本稿の企画カードは、述語ルールからの推論として、エージェントの get_record は該当するはずだと予測していました。18.3.0 では該当し、その証拠が B 行です。

閲覧行が記録するもの

測定した行を、ドキュメントの列の表に照らします。

列ドキュメント測定値(B 行、エージェント)
actionreadread
created_at「レコードが閲覧された瞬間であって、バッチが書き出された瞬間ではない」02:31:27.490Z。行の updated_at は 02:31:29.454Z で、2 秒後の書き出し
user_id「それを開いた sys_user の主体」HQ6GfWIx…、キーの発行先の識別子
actor「プリンシパルのラベル、user_id にフォールバック」HQ6GfWIx…、フォールバック値
object_name / record_id開かれたオブジェクトとレコードcrm_contact / Aa5lJEWl9nP1Hbuq
tenant_id「閲覧されたレコード自身の組織、閲覧者のセッション組織にフォールバック」org_mw8n…、連絡先自身の organization_id
old_value / new_value「常に null」null / null

この表の 2 つの細部が監査人にとって重みを持ちます。タイムスタンプは閲覧であって書き込みではないので、遅い台帳が出来事の時刻をずらすことはありません。テナントのスタンプは閲覧者ではなくレコードに従います。ドキュメントの理由は、組織 A のレコードについての行に閲覧者の組織 B のスタンプを押すと「組織 B のテナントの壁の向こうに落ち、その行が関わる唯一のテナント管理者から見えなくなる」からです。行 B では、その組織は連絡先自身のものです。シードされた連絡先は organization_id: org_mw8n… を持ち、これはこの実行のすべてのアカウントが加わった既定の組織なので、閲覧者のセッションへのフォールバックは通っていません。台帳自体には organization_id 列がありません。18.3.0 の sys_audit_log は systemFields: { tenant: false } を宣言しているので、プラットフォームは組織列を注入せず、書き込み側はレコードの組織を tenant_id にだけ写します(タグ時点の packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts 31 行目と read-audit.ts 651 行目)。

閲覧行が決して記録しない 4 つのこと

これはカバレッジ・マトリクスが間違えがちな半分であり、ドキュメントがまる 1 節を費やす半分です。4 つとも測定した行で観察されました。

  1. フィールド値は、決して。old_value と new_value は null のままです。フックは「セキュリティミドルウェアの内側、フィールドマスキングより前で走るので、目にするレコードはマスク前の平文」です。値をコピーすれば「フィールドレベルセキュリティが差し控えるまさにそのものの平文コピーを、コンプライアンス担当が広いアクセスを与えられる唯一のテーブルの中に鋳造する」ことになります。台帳は意図的に、閲覧者より少なくしか知りません。
  2. 閲覧者が実際に見たものではない。1 つ目から従います。行はレコードが開かれたと言うだけで、マスク後にどのフィールドが見えたかは決して言いません。「この人はどの列を見たか」を要求するコントロールに、この面は答えません。
  3. IP アドレスもユーザーエージェントもない。両方の列は sys_audit_log にあり、2 行とも null です。それらは login と logout の行に押され、閲覧行には押されません。A 行と B 行が同じに見えるのもこのためです。台帳が記録するのはプリンシパルであってチャネルではありません。エージェントの行には「MCP」「API キー」「エージェント」を示すものは何もなく、キーが属するユーザー HQ6GfWIx… があるだけです。
  4. 拒否された、または失敗した読み取りについては何も。読み取り E が証拠です。拒否された読み取りは「ここに痕跡を残さない」。残した痕跡はサーバーログの WARN [Security] Access denied 行で、それは別の面、別の保持期間です。

さらに 2 つの境界が、発見に委ねられるのではなく宣言されています。システム昇格された読み取りは行を書きません。session.isSystem を帯びるもの、たとえば api.sudo() のパス、数式の再計算、ロールアップは「プラットフォームが自分の帳簿のために読んでいるのであって、人がレコードを開いているのではない」。そして sudo() は呼び出し元のユーザー id を保つので、このフラグだけが両者を分けます。プリンシパルのない読み取りも行を書きません。「誰も名指さない行は、この機能が存在する目的であるその唯一のクエリにノイズを足すだけ」だからです。

「AI エージェントも含めて」の意味は、あなたが与えた識別子として

B 行は、条件付きでタイトルの問いに答えます。エージェントの閲覧は Dana のものと同じ台帳に、同じフックを通り、同じ 2 条件ルールの下で載りました。get_record は呼び出し元のプリンシパルの下での id による findOne だからです。設定すべきエージェント専用の監査パスはなく、忘れうるものもありません。

条件は上の 3 点目です。行はプリンシパルを名指すので、台帳がエージェントについて証明できることは、そのキーを発行した識別子の良さとちょうど同じです。18.3.0 の MCP 接続ガイドはその選択を率直に述べます。「フル権限のローカルエージェントには、プラットフォーム管理者か専用のサービス識別子でキーを発行し、スコープを絞ったエージェントには、持つべきアクセスをちょうど持つユーザーで発行する。」エージェントのキーを人のアカウントで発行すれば、台帳はその人がレコードを開いたと正直に報告します。測定した実行では、エージェントであるためだけに存在するユーザー agent-svc で発行しました。だからこそ B 行はエージェントに帰属できるのです。これはプロビジョニングの判断であって監査機能ではなく、本稿があなたに求める唯一の判断です。

行はどこにあり、どう失敗するか

os dev の下では、sys_audit_log はアプリケーションのデータベースにありません。その audit ライフサイクルクラスは、telemetry データソースが登録されていれば常にそこへルーティングし、dev サーバーは「既定で隣接する SQLite ファイルとしてひとつ用意する」。測定した実行の両ファイルをアプリ自身の SQLite ドライバで開くと:

objectstack.db            -> no such table: sys_audit_log
objectstack.telemetry.db  -> [{"action":"config_change","n":2},{"action":"create","n":19},
                              {"action":"login","n":6},{"action":"read","n":2},{"action":"update","n":6}]

read 行は冒頭の表の 2 行です。行があるはずの場所で監査クエリが空で返ってきたら、この分割が通常の原因で、プラグイン自身の失敗メッセージもそう言います。台帳書き込みでの「no such table」は「通常、書き込みがテーブルの作られたデータソースとは別のデータソースに対して実行されたことを意味する」。

頼る前に知っておくべきもうひとつは失敗の姿勢です。フックは「エンキューして戻る」。何も待たず、監査の失敗が有効な読み取りをエラーに変えることはありません。行は後のティックで、maxBatchSize 件の閲覧がバッファされるか、バッチ最初の閲覧から flushIntervalMs が過ぎたときに永続化され、destroy() はクリーンシャットダウン時に残りを書き出します。バッファ溢れ(10 000 件の固定上限を超えると最古から破棄、プロセスごとに一度だけ警告)も、台帳書き込みの失敗(そのバッチは失われ、原因ごとに一度報告)も再試行されません。ドキュメント自身の警告が、ランブックに書くべき一文です。両方の失敗は「他のどこからも見えない。読み取り自体は成功して 200 を返した……結果のクエリは、自信に満ちた、誤った、短い答えを返す。」

これが解決しないこと

  • 一覧と検索の読み取りは決して記録されない。「たまたまちょうど 1 レコードを返した一覧読み取りを含めて」。読み取り C が測定値です。カバレッジ・マトリクスは「これを『レコード詳細閲覧』と読むべきで、『読み取り』ではない」。一覧のエクスポート、レポートの実行、グリッドのページ送りは、ここに何も残しません。
  • 閲覧者の画面は台帳にない。行が証明するのは開いたことで、見えたものではありません。
  • 台帳の帰属性は、あなたの識別子の良さまで。3 点目の再掲です。共有キーや個人アカウントで発行したキーは、行を真でありながら役立たずにします。
  • 証跡を読むこと自体にゲートがある。Dana 自身の sys_audit_log?filter={"action":"read"} クエリは 403 PERMISSION_DENIED で、エージェントのキーで送った同じクエリも同じでした。sales_rep の権限セットは sys_audit_log に何も付与していないので、オブジェクトのゲートがどの行を判定するより前に拒否します。権限セットがこのテーブルに届く呼び出し元には、自分で読めるレコードについての閲覧行がすべて、本当の total 付きで提供されます。例外は view_all_audit_log 権限の保持者で、プラットフォーム管理者は既定で持ち、他の人は権限セット経由でのみ持ちます。18.3.0 では、プラットフォームの organization_admin 権限セット(sys_audit_log を読めるが view_all_audit_log は持たない)を持つ 4 つ目のアカウントに、この 2 行が "total": 2 で提供されました。行は 90 日間ホットで保持され、archive データソースが登録されていれば 7 年間アーカイブされます。Setup アプリでは、プラグインが提供する Diagnostics › Audit Log が監査ブラウザーを開き、そこで Record Views 一覧(created_at、user_id、record_id、actor)がプリセットとして提供されます。ObjectOS ではその面は読み取り専用かつ不変と文書化されています。

要件としては、どれも ObjectStack 固有ではありません。エージェントが読むどんな記録システムにも、これほど端の明示された閲覧証跡が要ります。このブログのインシデントの記事は書き込み側でプロンプトより実行時ガードレールを主張しました。読み取り側は同じ形で、爆発半径は小さく、記憶は長いのです。

エージェントをここに向ける

エージェントがあなたのアプリを書くなら、顧客データを持つすべてのオブジェクトについて設定済みの AuditPlugin を plugins に加えるよう頼み、同じことをすると称するメタデータキーは拒否させてください。18.3.0 にはそんなキーはないからです。そして diff をレビューします。import ひとつと配列エントリひとつ。エージェントがあなたのアプリを運用するなら、そのキーはエージェントであってそれ以外ではない識別子で発行し、誰かに聞かれる前に初日に上の台帳クエリを走らせてください。

npm create objectstack@latest my-app && cd my-app
pnpm add @objectstack/plugin-audit
# new AuditPlugin({ readAudit: { objects: ['…'] } }) を plugins に加えてから
npx os dev --compile

レコードを 1 件開き、2 秒待ち、action: read で sys_audit_log を問い合わせる。1 行、値なし。

それを実行するために、ObjectStack リポジトリは ObjectOS Community(ghcr.io/objectstack-ai/objectstack イメージ、os start、Console からなる、無料のセルフホスト版)を提供し、ObjectOS Cloud はホスト型のエディション、ObjectOS Enterprise はセルフマネージドのライセンス制のエディションです。