Rebounder Tech Blog

Written by the people who actually run these systems in production.

RLS Allowing actor_user_id NULL Erases Audit Trails

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

If an audit log INSERT policy allows actor_user_id IS NULL for any role, a compromised tenant account can record its actions as actor=NULL and vanish from the trail.

Conclusion

The RLS policy permitting INSERTs into the audit log allowed actor_user_id IS NULL unconditionally. A compromised tenant account could use that gap to record its own actions as actor=NULL and erase itself from the audit trail.

  • actor_user_id IS NULL was a condition that passed for every role
  • The only role that should be allowed NULL is system_admin, which performs cross-tenant internal work
  • Tenant roles (school_admin, teacher, and so on) should be rejected unless the value matches their own user_id exactly

The policy was rewritten to branch on role and reject NULL from tenant roles.

Symptom

INSERTs into the audit log were permitted by this policy.

CREATE POLICY audit_log_insert ON audit_log FOR INSERT
  WITH CHECK (
    (
      school_id IS NULL
      OR school_id = NULLIF(current_setting('app.current_school_id', true), '')::uuid
      OR current_setting('app.current_user_role', true) = 'system_admin'
    )
    AND (
      -- prevent actor_user_id spoofing: allow self, system_admin, or null
      actor_user_id IS NULL
      OR actor_user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid
      OR current_setting('app.current_user_role', true) = 'system_admin'
    )
  );

The comment says “prevent spoofing”, and indeed rewriting actor_user_id to someone else’s uuid was blocked. But actor_user_id IS NULL remained a condition that passed regardless of role.

That is what review flagged. If a tenant account (school_admin or teacher) is compromised, the attacker can insert into the audit log with NULL instead of their own user_id. The audit log exists to record who did what, so an action with a NULL actor is effectively unattributable. It was classified as a repudiation risk.

Cause

Allowing actor_user_id IS NULL was there for legitimate writes not tied to any tenant user — cross-tenant internal aggregation, inserts through the migrator.

But the policy’s condition was not split by role; it was a single monolithic “NULL passes” clause. The intent was to cover what system_admin does internally, yet a WITH CHECK expression applies equally to every session regardless of role. The intent was role-limited; the implementation never looked at the role.

The fix

Rewrite the actor_user_id side as a branch on role.

DROP POLICY IF EXISTS audit_log_insert ON audit_log;

CREATE POLICY audit_log_insert ON audit_log FOR INSERT
  WITH CHECK (
    (
      school_id IS NULL
      OR school_id = NULLIF(current_setting('app.current_school_id', true), '')::uuid
      OR current_setting('app.current_user_role', true) = 'system_admin'
    )
    AND (
      -- actor_user_id side:
      --   system_admin may supply NULL or any uuid (cross-tenant internal work)
      --   every other role: exact match on own user_id only (rejects NULL, prevents spoofing)
      current_setting('app.current_user_role', true) = 'system_admin'
      OR actor_user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid
    )
  );

system_admin passes unconditionally on the first OR; every other role can only pass the second condition, an exact match against the user_id set with SET LOCAL. Under SQL’s three-valued logic, NULL = uuid evaluates to NULL rather than true, so a tenant role supplying NULL for actor_user_id fails automatically. The monolithic “NULL passes” clause was re-embedded inside a role branch.

A verification gap found alongside it

In the PR that added this policy, CI did not fail the way it should have.

The setup that initialises the test database listed its migration files as constants.

const RLS_ENABLE_SQL = join(packageRoot, "migrations", "0001_enable_rls.sql");
const RLS_POLICIES_SQL = join(packageRoot, "migrations", "0002_rls_policies.sql");
const AUDIT_TRIGGER_SQL = join(packageRoot, "migrations", "0003_audit_trigger.sql");
const AUDIT_FK_SQL = join(packageRoot, "migrations", "0004_audit_fk.sql");

The migration adding the new policy existed in the repository but was not in that list. The test database kept the old policy allowing NULL, so the test meant to verify the new one — “a tenant role supplying NULL is rejected” — was in fact running against the old policy. The test code itself was correct; the database under test had simply never applied the new migration, so the behaviour it was written to check was never checked.

The fix was to add the migration to the list and to the execution order.

const AUDIT_LOG_ACTOR_NULL_SQL = join(
  packageRoot,
  "migrations",
  "0005_audit_log_actor_null_school_admin.sql",
);

With that, CI’s test database applies the new policy and the rejection of NULL from tenant roles is genuinely exercised.

Making it less likely to recur

When writing an RLS policy condition, you have to read the expression itself and confirm that “the target you intended” and “the target the implementation names” are the same. The single line actor_user_id IS NULL reads as a blanket allowance to every role unless you also read the comment describing the intent.

And fixing the policy is not the end of it. Whether the test database reflects a migration is a separate thing to confirm from whether the migration file exists. Adding a migration means checking, in the same change, that the test setup registers it for execution.

よくある質問

Q1Why was actor_user_id IS NULL allowed at all?

To let through legitimate writes not tied to a specific tenant user: cross-tenant internal aggregation, inserts via the migrator. The original policy just never narrowed that allowance by role, so school_admin and teacher — which should only ever write their own user_id — got NULL too.

Q2What can actually go wrong with that design?

If a tenant account (school_admin or teacher) is compromised, the attacker can insert audit rows with actor_user_id set to NULL. The audit log exists to record who did what, so an action with no actor is effectively impossible to attribute. It was treated as a repudiation risk.

Q3What does the condition look like after the fix?

Only the system_admin role may supply NULL or an arbitrary uuid; every other role may insert only when actor_user_id exactly matches the user_id set via SET LOCAL. In SQL, comparing NULL to a uuid yields NULL rather than true, so a tenant role supplying NULL is rejected automatically.

Q4Why did CI miss the fix once it was made?

The migration was in the repository, but the test-database setup never registered it for execution. CI kept the old policy allowing NULL, so the test meant to verify the new policy ran against the old one. The migration existed; the tests validated pre-fix behaviour.

確認した環境

  • PostgreSQL RLS / drizzle-orm ^0.45.2 / postgres (npm) ^3.4.5
  • 2026-05-30, fixed in migration 0005 as follow-up to a Medium review finding

この記事の根拠

  • SQLファイル 253〜268行目コミット da0605a
  • SQLファイル 1〜45行目コミット f2e1a48
  • TypeScriptファイル 10〜13行目コミット b6c4623
  • TypeScriptファイル 14〜18行目コミット f2e1a48

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。