BASE44DEVS

FIX · SECURITY · CRITICAL

Base44 RLS Drift After AI Edit — Fix the Silent Permission Bug

Base44's AI builder updates schemas and queries but does not always update the matching row-level security rules, so rules and code drift apart. The bug is silent because a denied list() or filter() returns an empty result, not an error. Fix sequence: record every entity's security rules, diff the fields they compare against the SDK calls in your code, and write a per-role verification test that fails loudly on drift.

Last verified
2026-10-17
Category
SECURITY
Difficulty
HARD
DIY possible
NO

What's happening

Base44 RLS rules drift out of sync with code after AI builder edits because the builder treats schema, queries, and row-level security rules as three separate concerns and only updates whichever surfaces appear in the prompt. Adding a tenant_id field to a table will update the schema and most read paths, but the existing Read rule that compared org_id is left untouched. The bug is silent — a denied list() or filter() returns an empty result, not an error — so monitoring catches nothing and the first symptom is usually a user seeing another tenant's data weeks later. Fix it by recording every entity's security rules, building a coverage matrix that diffs the fields each rule compares against every base44.entities call site, writing a per-role smoke test that asserts allowed-record counts and denied-record counts, and gating the suite as a deploy blocker on every AI-builder commit.

A user logs in. They see a dashboard with records belonging to another tenant. Or the opposite — they see an empty list when they should see fifty rows. There is no error in your logs. The SDK call returned successfully. The data is in the database. Something between the database and the user is filtering wrongly.

This is the silent-failure mode unique to AI-builder platforms: the AI builder edited your schema, your queries, and your forms, but it did not edit the row-level security rule that gates the data. The rule and the code are now out of sync. Every read goes through a stale rule, and the rule either lets too much through or too little. Either way, no error fires.

This is a new class of bug. Traditional teams using hand-written SQL migrations have reviewers who catch policy drift in pull requests. Teams using AI builders have no review surface — the agent shipped the change directly. Base44's AI builder can add a tenant_id field to a table, rewrite every read query to filter on it, generate a new admin form, and leave the existing Read rule comparing the now-deprecated org_id field. The schema migrates. The queries migrate. The rule does not. The result is a permission bug that lives in production until a human notices.

Because nothing errors, drift can go unnoticed through several AI-builder turns, and each turn compounds it.

Why AI builders create RLS drift

The AI builder operates on the surfaces it sees in your prompt. When you ask it to "add multi-tenant support to the orders table," it interprets that as a schema-and-code task. It reads the table definition, adds a field, finds every read in the codebase, rewrites the filters, regenerates the form, and reports success. The regeneration is impressive. It is also incomplete.

The security rules live in a different surface from the code that queries the data: each table's Permissions page in the dashboard, backed by the rls block of the entity schema (Managing data permissions and entity security, both read 17 Oct 2026). The agent does not always pull that surface into context. Even when it does, nothing in the edit runs the rule as a test user to verify it is correct. There is no execution feedback loop for RLS the way there is for compilation errors or failed tests. The agent emits whatever rule looks plausible from the prompt and moves on.

Three structural reasons compound the problem.

First, RLS evaluation is silent. A failed type-check produces an error the agent can read and correct. A denied read produces an empty result the agent has no way to interpret. Without a signal, there is no correction.

Second, RLS lives outside the code surface. The AI builder's training data is dominated by application code where the security check is colocated with the query (decorators, middleware, ORM hooks). Base44's model separates them: the rule belongs to the entity, not to the call. The agent's instinct from training is "the security check is right there in the function" — but in Base44 the check is in the entity's security rules, which the agent may not be inspecting on this turn.

Third, AI builders optimize for visible progress. The user prompted for "multi-tenant support" and the agent has already produced a schema migration, a form, three new queries, and a passing build. From the agent's reward signal, the task looks done. Surfacing "by the way, you also need to update the Read rule on the Order table, here is the new rule" is not in its training distribution.

The net effect: any AI-builder feature addition that touches a new table or a new tenant boundary can leave the security rules behind. The drift is silent. The bug is real. And every additional AI-builder turn makes the divergence harder to trace.

The 6 silent-failure patterns to check

Each pattern below has a specific signature, a specific cause, and a specific way to detect it on Base44.

Pattern 1: New reference field, the rule still compares the old one.

  • Symptom: Zero rows returned to a user who should see many. Admin view shows the rows exist.
  • Cause: AI builder added team_id and migrated reads, but the table's Read rule still compares data.org_id with {{user.data.org_id}}.
  • Detect: List every field on the table that holds a tenant, team or owner reference, then list every field the table's rules compare. Any reference field not in any rule is a candidate orphan.

Pattern 2: New table, left on All Users.

  • Symptom: Cross-tenant data leakage. Users see other users' records.
  • Cause: AI builder created the table with its Read rule on All Users, which Base44 defines as "Anyone can perform this action, even without signing in" (Managing data permissions, read 17 Oct 2026), and never came back to lock it down.
  • Detect: On the Data page, open each table's Permissions page and list every table whose Read rule is All Users. Check what each one holds; any that holds personal data is your unguarded surface. A Permission risks detected banner on the table, or a security scan from the Security tab, can flag the same tables.

Pattern 3: The rule compares a user field that is no longer populated.

  • Symptom: Some users see their records and others see none, for example accounts created after the AI edit.
  • Cause: The rule compares data.tenant_id with {{user.data.tenant_id}}, but the AI builder moved tenancy to a new User field (org_id) and only populates that one.
  • Detect: List every {{user.data.*}} field your rules reference and check, on real records in the User table, that each one is populated. Test what a user with an empty field sees rather than assume.

Pattern 4: Write path bypasses the Read rule.

  • Symptom: Users can write records they cannot subsequently read. Records appear orphaned.
  • Cause: The Create rule is more permissive than the Read rule. AI builder relaxed Create to ship a new form and did not touch Read, or the new form does not write the field the Read rule compares.
  • Detect: For every table, compare the Create rule with the Read rule. Asymmetry is a flag, not always a bug, but worth checking.

Pattern 5: A field was renamed in the schema but not in the rule.

  • Symptom: All queries to the table return zero rows. Admin view works.
  • Cause: Schema rename succeeded; the rule still names the old field, so it no longer matches the records your code writes, and a denied read returns an empty result rather than an error.
  • Detect: Check every field name in the rules against the table's current fields (Dashboard, then the table under Data). Base44's docs say a rule's Entity field must be "a field that already exists on this data table", typed "exactly as it appears" (Managing data permissions, read 17 Oct 2026). Then confirm with Test Permissions in preview.

Pattern 6: Role expansion not reflected in the rules.

  • Symptom: New role (e.g., "billing") gets blocked from records they should access.
  • Cause: AI builder added a new role value, but every existing User Property Check rule names the allowed roles and does not include the new one. Base44's built-in User Role is Admin or User; anything finer is a custom User field such as access_level, checked with User Property Check (Managing data permissions, read 17 Oct 2026).
  • Detect: List the values your role field takes in the User table. For each value, find the rules that name it. Values missing from the rules are likely under-permissioned.

The pattern → cause → detect comparison fits in one table:

SymptomLikely RLS patternHow to detect
Zero rows where many expectedRule compares a stale fieldDiff reference fields vs rule fields
Cross-tenant leakageNew table left on All UsersList tables whose Read rule is All Users
Some users see nothing, others see their recordsRule reads an unpopulated user fieldCheck each {{user.data.*}} field on real User records
Write succeeds, read returns nothingCreate/Read asymmetryCompare the Create and Read rules
All reads zeroRenamed field not updated in the ruleCheck rule fields against the table's fields, then Test Permissions in preview
New role blockedRole value missing from rulesDiff role values vs roles named in rules

The verification audit — 7 steps

This is the audit we run when we are called in. It works whether you have one drift or twelve. Allow a full day for a mid-size app the first time through; subsequent runs are faster once the matrix is built.

Step 1: Record every entity's security rules

For each table, click Dashboard in the app editor, click the table under Data, then click Permissions, and copy the Generated JSON (Preview) section at the bottom of the page. Base44's docs say it "shows how your rules translate into the technical format Base44 uses behind the scenes" (Managing data permissions, read 17 Oct 2026). In a CLI project, read the rls block of each entity schema file instead: "Security rules are part of your entity schema" (entity security, read 17 Oct 2026). Capture each table's rule for every action (Create, Read, Update, Delete) and every entity field and user property it compares. Save it as JSON — you will diff it later.

Step 2: Enumerate every SDK call site

Grep the codebase for entities., which matches both base44.entities. and base44.asServiceRole.entities. calls. For each match, record the entity name, the method (list/filter/get/create/update/delete), the fields it filters on or writes, and the file:line. Flag every asServiceRole call: the service role "bypasses both RLS and FLS entirely" (entity security, read 17 Oct 2026), so only your own checks guard it. This is your code-side surface.

Step 3: Build the coverage matrix

Cross-product the two exports. Rows are tables. For each table, columns are: rules-on-this-table, rule-fields, query-fields-from-code, actions-covered. Drift shows up as a row where the rule fields and the query fields disagree.

Step 4: Identify orphan rules

A rule is orphaned if it compares a field no live query filters on or writes (server-set fields such as created_by excepted). Either the field was renamed and the rule lags, or the rule is dead weight from a previous schema. Both cases need investigation. Mark every orphan with the suspected cause.

Step 5: Identify unguarded queries

A query is unguarded if the table's rule for that action is All Users, or if it runs through asServiceRole. Base44 "automatically sets up data permissions as you build your app" (Managing data permissions, read 17 Oct 2026), so never assume those rules still fit after an AI edit; check. Every unguarded query on private data is a leak candidate.

Step 6: Write a smoke test per role

For every role in your app, write a test that logs in as a real user with that role and runs every protected query. Assert row counts and content. Then run a query the role should not be able to do and assert zero rows.

To check by hand first, click Test Permissions in preview, next to Edit on a table's Permissions page: it opens the app's preview as a person you pick. Base44 warns that "Changes you make while testing are saved to that person's data" (Managing data permissions, read 17 Oct 2026), so use sample users.

// tests/rls/orders.role-smoke.test.ts
import { createBase44Client } from "@/lib/base44";
import { signInAs } from "./helpers";

describe("orders RLS — per-role smoke", () => {
  test.each([
    { role: "admin",   expectMin: 100, expectOtherTenant: true  },
    { role: "member",  expectMin: 1,   expectOtherTenant: false },
    { role: "billing", expectMin: 0,   expectOtherTenant: false },
  ])("$role sees only its own tenant", async ({ role, expectMin, expectOtherTenant }) => {
    const session = await signInAs(role);
    const client = createBase44Client(session.token);

    const own = await client.entities.Order.filter({
      tenant_id: session.tenantId,
    });
    expect(own.length).toBeGreaterThanOrEqual(expectMin);

    const other = await client.entities.Order.filter({
      tenant_id: "TENANT_NOT_MINE",
    });
    if (expectOtherTenant) {
      expect(other.length).toBeGreaterThan(0);
    } else {
      // Negative assertion — RLS must filter this out.
      expect(other.length).toBe(0);
    }
  });
});

The negative assertion (expect(other.length).toBe(0)) is the load-bearing line. A denied filter() returns "an empty result, the same as if nothing matched" (entity security, read 17 Oct 2026), so this is the only place the bug surfaces.

Step 7: Automate the audit on every AI-builder commit

Wire the matrix-builder and the smoke suite into a pre-deploy check. Use a script outline like this:

// scripts/audit-rls.ts
// Rules come from the repo: the entity schema files in a CLI project,
// or the Generated JSON you saved in step 1.
const rules   = await loadSecurityRules("./base44/entities");
const queries = await scanCodebaseForSdkCalls("./src");

const drift = diffRulesVsQueries(rules, queries);
if (drift.length > 0) {
  console.error("RLS drift detected on tables:", drift.map(d => d.table));
  process.exit(1);
}

const smoke = await runRoleSmokeSuite();
if (smoke.failures.length > 0) {
  console.error("Role smoke failures:", smoke.failures);
  process.exit(1);
}

The diff function compares the two surfaces:

function diffRulesVsQueries(rules: SecurityRule[], queries: SdkCall[]) {
  return rules
    .map(rule => {
      const queriesOnTable = queries.filter(q => q.table === rule.table);
      const queryFields = new Set(queriesOnTable.flatMap(q => q.fields));
      const ruleFields = new Set(rule.referencedFields);

      const orphan = [...ruleFields].filter(f => !queryFields.has(f));
      const unguarded = [...queryFields].filter(f => !ruleFields.has(f));

      return { table: rule.table, orphan, unguarded };
    })
    .filter(d => d.orphan.length > 0 || d.unguarded.length > 0);
}

Run it on every commit that touches src/ or the saved rules. Fail the deploy on drift. Once this gate exists, the AI builder cannot ship a silent regression — it shows up as a red CI run instead.

What this audit looks for

This is a checklist of what to look for, mapped to the six patterns above. It is not a tally of past audits.

  • Orphaned rules (Patterns 1 and 5). A rule that compares a field the code no longer filters on or writes, for example after a rename or a new tenant field.
  • Tables left on All Users (Pattern 2). Check what each one holds. Email addresses, phone numbers, payment or health data on an open table are a disclosure, not just drift.
  • Create/Read asymmetry (Pattern 4). Users can create records they cannot read back, which surfaces as ghost-record support tickets.
  • Empty user fields behind a rule (Pattern 3). A {{user.data.*}} field the rules depend on that some accounts never get.
  • Role values missing from rules (Pattern 6). A role the AI builder added that no existing rule names.
  • Service-role calls without their own checks. Every asServiceRole call skips the rules (Step 2), so each one needs an authorization check inside the function.
  • No per-role smoke test. If the app has never run one, its rules may never have been checked as a real user.

If the app handles regulated data, treat any open table that holds it as a disclosure risk and close it before the next AI-builder edit ships.

Why this is the silent killer of Base44 production launches

A Base44 production failure does not need a missing feature or bad UX. An invisible permission bug introduced by an AI-builder edit nobody reviewed is enough. The features ship. The build passes. Then, long after the change shipped, a customer notices and the incident is already in production telemetry, billing systems, and (in regulated cases) audit logs.

This deserves to be its own discipline. Traditional secure-coding reviews assume a human wrote the code and a human reviewed the diff. AI-builder edits violate both assumptions. The edit is large, the diff is sprawling, and the reviewer (if any) reads a diff rather than testing the rules as a real user. The result is a category of bug that is structurally invisible to the workflows most teams already have.

We call this discipline AI-Builder Verification. It is not pen-testing. It is specifically: take the agent's output, treat it as untrusted, and verify the rules and the code agree. The verification is mechanical (the seven steps above), but judgment about what the rules should be still requires a human who understands the data model and can design role-based permissions that hold across future AI-builder edits. That combination — mechanical drift detection plus human policy review — is what we deliver in our Base44 AI Builder Audit engagement.

If your app is in production on Base44 and you have not run this audit, you do not know whether your rules still match your code. If you operate under regulated data, you cannot DIY this — get an external review before the next AI-builder edit ships. Scope an audit through /base44-debugging-help, or read the related fixes for AI-induced regressions broadly, SSO and auth bypass, and silent data loss on return — all descend from the same AI-builder verification gap.

QUERIES

Frequently asked questions

Q.01How does the AI builder break RLS without anyone noticing?
A.01

The AI builder treats schema, queries, and security rules as three separate concerns and only updates whichever surfaces it sees in the prompt. Adding a tenant_id field to a table will update the schema and most read paths, but the existing Read rule that compared org_id is left untouched. There is no integration test that runs the rule as a real user, so the divergence never raises an error. The first symptom is usually a support ticket from a user who can see another tenant's data, weeks after the change shipped. By that point, multiple AI-builder turns have layered on top of the original drift, and tracing which prompt introduced the gap is no longer trivial.

Q.02What's the fastest way to detect drift between RLS and queries?
A.02

Build a coverage matrix. List every table and its security rules on the left axis. List every SDK call site (search the codebase for entities., which also catches asServiceRole.entities. calls that skip the rules) on the right axis. Mark the fields each rule compares, then mark the fields each call site filters on or writes. Any row where the rule's field and the query's field do not match is a drift candidate. Automating it as a script that runs on every commit takes about a day and pays for itself the first time the AI builder edits a write path. Without the matrix, you are debugging blind every time a user reports a permission bug.

Q.03Why don't I get an error when RLS denies a query?
A.03

Row-Level Security in Base44 returns an empty result when a rule filters records out of a list() or filter() call, not an error. Base44's security docs say 'Denied reads behave as if the data doesn't exist, so they don't reveal what's there'; a denied get() throws a 404 'whether or not the record exists', and only denied writes throw a permission error (docs.base44.com, read 17 Oct 2026). From the SDK's perspective, the query succeeded and returned zero records — semantically identical to a query that legitimately matched nothing. There is no distinction at the API surface between 'no records exist' and 'records exist but you cannot see them.' This is a deliberate design choice (it prevents data-existence enumeration attacks), but it means RLS misconfigurations are invisible to your error-monitoring stack. The bug surfaces only when a human notices missing data or, worse, sees data they should not.

Q.04Can I write tests that catch this before users do?
A.04

Yes, and this is the only reliable safeguard. For every role in your app (admin, member, anonymous, billing, support, etc.) write a smoke test that authenticates as that role, runs every read and write that role is supposed to be able to do, and asserts the result count and contents. Add a negative test that runs a query the role should not be able to do and asserts zero rows. Run the suite on every AI-builder commit. The tests are tedious to write the first time but you only write them once per role; after that they run forever. A suite that runs on every AI-builder commit flags a regression the same day the AI builder ships it, before the change reaches production.

Q.05Is this safe to fix myself or should I get an audit?
A.05

If you have one or two affected tables and you know your data model well, you can patch the immediate gap yourself in a few hours. The reason we say diyPossible is false is that finding all the gaps is the hard part, not fixing each one: an orphaned rule raises no error, and a table left on All Users can hold personal data unnoticed. Without an external review, you do not know what you do not know. If your app handles regulated data (PHI, financial, EU personal data), do not DIY this — a single missed rule is a breach-class bug, and an audit costs far less than a disclosure.

Q.06How do I prevent this on the next AI builder change?
A.06

Three controls, layered. First, keep every entity's security rules in your repo (in a CLI project they are part of each entity schema file; in an editor-built app, save each table's Generated JSON after every AI-builder change) so any rule change the AI builder makes shows up as a diff. Second, run the per-role smoke suite from question four on every AI-builder commit, gated as a deploy blocker. Third, add an explicit sentence to every prompt that touches schema or queries: 'After this change, list every security rule that may need updating and update them. Do not modify any other rule.' None of the three is sufficient alone. Together they turn silent drift into a failed check you see before your users do.

NEXT STEP

Need this fix shipped this week?

Book a free 15-minute call or order a $497 audit. We will respond within one business day.