Securing Fliplet data sources

Securing Fliplet data sources

How to protect data sources with access rules, required-data validation, column-level restrictions, and custom JavaScript security rules.

Contents

Security rules

Access to Data Sources is secured via the Access Rules tab of the App Data section in Fliplet Studio. Each rule controls who can access the data source, what operations they can perform, and what conditions must be met.

Rules are evaluated from top to bottom. The first rule that grants access is used and evaluation stops — later rules are not checked. If no rules match, access is denied by default. If no rules are defined at all, all access is denied — data sources are locked down until you explicitly add rules.

For every operation, unmet require conditions skip that rule and evaluation continues. A later matching rule can grant access to the same read or write. Restrictive rules do not override broader grants. See Data requirements for details.

Access rule structure

Each access rule is a JSON object with the following properties:

Property Type Required Description
type Array of strings Yes Operations this rule applies to: "select", "insert", "update", "delete"
allow String or Object Yes Who can access: "all", "loggedIn", { "user": {...} }, or { "tokens": [...] }
enabled Boolean No Whether the rule is active (defaults to true). Set to false to disable the rule without deleting it — disabled rules are skipped during evaluation
include Array of strings No Allowlist of returned columns for reads, taking precedence over exclude. Does not enforce a write allowlist
exclude Array of strings No Columns hidden from reads (unless include is present). Individual inserts and updates omit these fields before saving
require Array No Data requirements that must be met (see Data requirements)
appId Array of numbers No Restrict this rule to specific app IDs (applies to all apps if omitted)
name String No Descriptive label for identifying the rule in Studio
script String No Custom JavaScript code for advanced security logic. When present, allow and type are ignored — see Custom security rules

Defining who can access

The allow property supports four modes:

All users (including anonymous):

{ "allow": "all" }

Logged-in users only:

{ "allow": "loggedIn" }

Specific users (filtered by session data). allow.user checks the logged-in user’s identity — use it with literal values to control who can access. To control which records they can access, use require instead (see Data requirements):

{
  "allow": {
    "user": {
      "Role": { "equals": "Admin" }
    }
  }
}

User filters support three operators: equals, notequals, and contains. Multiple conditions in the same user object are combined with AND logic. For OR logic, create separate rules instead.

Important: allow.user conditions check the logged-in user's session data — not data source records. Always use literal values here (e.g., "Admin", "Manager"). Although Handlebars syntax is supported, using it in allow.user (e.g., {{ user.[Department] }}) compares the user's field against itself — always true, granting access to any logged-in user. Handlebars templates are useful in require conditions, where they scope queries by the user's data. See Data requirements.

{
  "allow": {
    "user": {
      "Role": { "equals": "Manager" },
      "Status": { "notequals": "Inactive" }
    }
  }
}

Specific API token:

{
  "allow": {
    "tokens": [42857]
  }
}

Restricting columns

For reads, use include to allow specific returned columns or exclude to hide columns. If both are specified, include takes precedence. Without either, all columns are returned. For individual inserts and updates, exclude removes named fields before saving; include does not restrict writes. For an exact write allowlist, validate the proposed payload in a custom security rule.

{
  "type": ["select"],
  "allow": "loggedIn",
  "exclude": ["Password", "SSN", "Salary"]
}

Security note: Always exclude privilege fields (e.g., Admin, Role, Permissions) from rules that allow logged-in users to insert or update records. Otherwise, users can escalate their own privileges.

Example: role-based access with protected fields

This example shows an “Employees” data source where admins have full access, regular users can read records (without sensitive fields), and can only update their own record or insert records with a non-privileged role.

Data source columns:

Email First Name Role Department Salary Password Admin Permissions
alice@acme.com Alice Admin Engineering 150000 ••••• Yes all
bob@acme.com Bob User Marketing 85000 ••••• No read
carol@acme.com Carol User Engineering 95000 ••••• No read

Security rules:

[
  {
    "type": ["select", "insert", "update", "delete"],
    "allow": { "user": { "Role": { "equals": "Admin" } } },
    "enabled": true
  },
  {
    "type": ["select"],
    "allow": "loggedIn",
    "exclude": ["Password", "Salary"],
    "enabled": true
  },
  {
    "type": ["update"],
    "allow": "loggedIn",
    "require": [
      { "Email": { "equals": "{{user.[Email]}}" } }
    ],
    "exclude": ["Role", "Admin", "Permissions"],
    "enabled": true
  },
  {
    "type": ["insert"],
    "allow": "loggedIn",
    "require": [
      "Email",
      "First Name",
      { "Email": { "equals": "{{user.[Email]}}" } },
      { "Role": { "equals": "User" } }
    ],
    "exclude": ["Admin", "Permissions"],
    "enabled": true
  }
]

Queries that succeed:

When Alice (Admin) is logged in, the first rule matches — she has full access to all records and columns:

// JS API — Alice reads all records including sensitive fields
const connection = await Fliplet.DataSources.connectByName('Employees');
const allRecords = await connection.find();
// Returns all 3 records with all columns including Salary and Password
// REST API equivalent
POST /v1/data-sources/123/data/query
Auth-token: <Alice's token>
Content-Type: application/json

// "type" here is the REST API operation type, not a security rule field
{ "type": "select" }

// Response (200 OK): all entries with all columns

When Bob (User) is logged in, the second rule matches for reads — he can see records but Password and Salary are excluded:

// JS API — Bob reads records (sensitive columns automatically excluded)
const connection = await Fliplet.DataSources.connectByName('Employees');
const records = await connection.find();
// Returns all 3 records, but each record is missing Password and Salary
// e.g. { Email: "alice@acme.com", "First Name": "Alice", Role: "Admin", Department: "Engineering", Admin: "Yes", Permissions: "all" }

Bob can update his own record (the third rule checks the stored record’s email):

// JS API — Bob updates his own name
const connection = await Fliplet.DataSources.connectByName('Employees');
await connection.update(456, {
  Email: 'bob@acme.com',
  'First Name': 'Robert'
});
// Succeeds: the stored Email matches Bob's session; protected fields are excluded
// REST API equivalent
PUT /v1/data-sources/123/data/456
Auth-token: <Bob's token>
Content-Type: application/json

{ "Email": "bob@acme.com", "First Name": "Robert" }

// Response (200 OK): updated entry

Queries that fail:

When a query is denied, the API responds with HTTP status 400 and a JSON error body. The message field changes based on the denied operation — for example, "do not allow this app to read data" for selects, or "do not allow this app to insert data" for inserts:

{
  "message": "The security rules for the Data Source \"Employees\" do not allow this app to read data.",
  "type": "datasource.access",
  "payload": { "dataSourceId": 123 }
}

In the JS API, the promise is rejected with this error object. The message adapts to include the data source name and the specific operation that was denied (read, insert, update, or delete). The type is always "datasource.access" and payload.dataSourceId identifies the data source.

// Bob tries to update another user's record — fails
// (The stored Email does not match Bob's session, regardless of the submitted Email)
await connection.update(789, {
  Email: 'carol@acme.com',
  'First Name': 'Carolina'
});

// Bob tries to escalate his own role — fails
// (Role is excluded from the update rule, so it cannot be written)
await connection.update(456, {
  Email: 'bob@acme.com',
  Role: 'Admin'
});

// Bob tries to insert a record with an admin role — fails
// (the insert rule requires Role: "User")
await connection.insert({
  Email: 'bob@acme.com',
  'First Name': 'Bob Clone',
  Role: 'Admin'
});

// Bob tries to delete a record — fails
// (no delete rule matches for non-admin users)
await connection.removeById(789);

Example: department-scoped access

In this example, managers see all records for their department, regular users only see their own record, and inserts are restricted to the user’s department. Department scoping is enforced through require conditions on the query, not through allow.user (which checks the user’s identity, not the data being accessed).

Data source columns:

Email Name Role Department Salary ManagerNotes
alice@acme.com Alice Manager Engineering 150000 Top performer
bob@acme.com Bob User Engineering 85000  
carol@acme.com Carol User Marketing 95000  
dave@acme.com Dave Manager Marketing 140000 Needs training

Security rules:

[
  {
    "type": ["select"],
    "allow": {
      "user": { "Role": { "equals": "Manager" } }
    },
    "require": [
      { "Department": { "equals": "{{user.[Department]}}" } }
    ],
    "exclude": ["Salary"],
    "enabled": true
  },
  {
    "type": ["select", "update"],
    "allow": "loggedIn",
    "require": [
      { "Email": { "equals": "{{user.[Email]}}" } }
    ],
    "exclude": ["Salary", "ManagerNotes"],
    "enabled": true
  },
  {
    "type": ["insert"],
    "allow": "loggedIn",
    "require": [
      "Name",
      { "Department": { "equals": "{{user.[Department]}}" } },
      { "CreatedBy": { "equals": "{{user.[Email]}}" } }
    ],
    "exclude": ["Role", "Admin"],
    "enabled": true
  }
]

The first rule uses allow.user to check the user’s role (a literal match — “is this user a Manager?”) and require to enforce department scoping on the query (the where clause must include the user’s own department). This combination ensures only managers can use the rule and they can only query their own department’s data.

Queries that succeed:

When Alice (Manager, Engineering) is logged in, the first rule matches — she is a Manager, and her query includes Department: 'Engineering' which satisfies the require condition:

// JS API — Alice reads all Engineering records
const connection = await Fliplet.DataSources.connectByName('Staff');
const records = await connection.find({
  where: { Department: 'Engineering' }
});
// Returns Alice and Bob's records, excluding Salary
// e.g. { Email: "bob@acme.com", Name: "Bob", Role: "User", Department: "Engineering", ManagerNotes: "" }
// REST API equivalent
POST /v1/data-sources/123/data/query
Auth-token: <Alice's token>
Content-Type: application/json

{ "type": "select", "where": { "Department": "Engineering" } }

// Response (200 OK): Alice and Bob's entries, Salary excluded

When Bob (User, Engineering) is logged in, the first rule does not match (wrong role). The second rule matches — he must filter by his own email:

// JS API — Bob reads his own record
const connection = await Fliplet.DataSources.connectByName('Staff');
const records = await connection.find({
  where: { Email: 'bob@acme.com' }
});
// Returns Bob's record only, excluding Salary and ManagerNotes

Bob can insert a record in his own department:

// JS API — Bob creates a record in Engineering
await connection.insert({
  Name: 'New Hire',
  Department: 'Engineering',
  CreatedBy: 'bob@acme.com'
});
// Succeeds — all require conditions are met, Role and Admin are excluded from writes
// REST API equivalent (Fliplet uses PUT for both inserts and updates on this endpoint)
PUT /v1/data-sources/123/data
Auth-token: <Bob's token>
Content-Type: application/json

{ "Name": "New Hire", "Department": "Engineering", "CreatedBy": "bob@acme.com" }

// Response (200 OK): new entry

Queries that fail:

// Alice (Manager) tries to read Marketing records — fails
// (the require condition demands Department matching Alice's own department "Engineering";
// "Marketing" does not match, so the first rule is skipped; no other rule grants broad access)
await connection.find({
  where: { Department: 'Marketing' }
});

// Bob tries to read all records without filtering by email — fails
// (the second rule requires Email matching Bob's session; without a where clause,
// the require condition is not met, the rule is skipped, and no subsequent rule grants access)
await connection.find();

// Bob tries to read Alice's record — fails
// (Email does not match Bob's session)
await connection.find({
  where: { Email: 'alice@acme.com' }
});

// Bob tries to insert a record in Marketing — fails
// (Department does not match Bob's department "Engineering")
await connection.insert({
  Name: 'Spy',
  Department: 'Marketing',
  CreatedBy: 'bob@acme.com'
});

// Bob tries to insert with a different CreatedBy — fails
// (CreatedBy must equal Bob's email)
await connection.insert({
  Name: 'Fake',
  Department: 'Engineering',
  CreatedBy: 'alice@acme.com'
});

Data requirements and query validation

The require property defines conditions that incoming queries must satisfy. This is not the same as querying data — it controls how queries are assessed against the rule’s data requirements.

Components such as List from Data Source, Chart and Form may issue broad queries that do not satisfy your read requirements. Configure their filters or use custom queries that satisfy the intended restrictions. Adding a permissive fallback rule grants broader access even when a stricter rule appears first; an appId restriction scopes a rule to apps, not to individual components.

The behavior of require varies by operation type:

  • For select queries: Requirements validate the submitted filter. For example, if a rule requires { "Email": { "equals": "{{user.[Email]}}" } }, a find() call must include where: { Email: user.Email } (or an equivalent supported filter) for that rule to grant access. Direct reads by entry ID instead validate the stored entry data. To obtain session data client-side, use the Session API.
  • For delete operations: Direct deletion by entry ID validates the stored entry data and does not require a client where filter. Query-based deletion requires a nonempty filter and read access, then validates delete access against each matched row’s stored data. Explicit commit deletion IDs provide an array of existing row data; other bulk deletion paths, such as replacing imported data, can omit row data. Do not assume every delete evaluates a submitted filter or always provides stored rows.
  • For insert operations: Required fields and value conditions validate the submitted data.
  • For update operations: String requirements check that the submitted data includes the named fields. Object-form value conditions check the existing stored row, including stored rows loaded for commit updates. They do not restrict proposed replacement values. Use a custom rule with payload checks to enforce allowed new values or an exact set of write fields.

Rules grant access independently: If any require condition fails, that rule does not grant access and evaluation continues for reads and writes alike. Access is denied only if no applicable rule grants it. Remove or narrow any other rule that would admit requests your restrictions are intended to reject; ordering a strict rule first does not make a broader fallback safe.

Data requirement types

require accepts an array of strings (required field names) and objects (field conditions). Four requirement types are available:

Requirement type Description Query patterns that satisfy this requirement
Field is required The query must include the specified column Any query that provides the column as a key
Field equals The query value for the column must match exactly { Field: value } or { Field: { $eq: value } }
Field not equals The query value for the column must not match the specified value { Field: { $ne: value } } or any query where the field’s value differs from the specified value
Field contains The query value for the column must contain the specified substring { Field: value } or { Field: { $iLike: value } } or { Field: { $like: value } }

These requirement types (equals, notequals, contains) are not query operators — they define how the security rule validates incoming queries. For the full list of query operators you can use when reading and writing data, see the query operators reference.

Handlebars templating

Condition values can reference the logged-in user’s session data using Handlebars syntax. This enables dynamic, per-user access scoping. Use bracket notation (e.g., [Email]) for field names — it is required for names with spaces or special characters (e.g., {{user.[First Name]}}), and also works for simple names. Plain dot notation ({{user.Email}}) works only for field names without spaces.

For per-account ownership, use an owner key that is unique to each account, stable and protected from end-user changes through every write path. This requirement applies to the owner key; shared fields such as Role and Department do not need to be unique. Before matching an account to private records already assigned to an email address or external identity, verify control of that identity or provision the account through a trusted process. A self-chosen email at public signup does not establish entitlement to those records. Public signup can support users creating their own records when a stable account key and server-enforced rules bind those records to the authenticated account.

  • {{user.[Email]}} — the user’s email address
  • {{user.[Department]}} — the user’s department
  • {{user.[Role]}} — the user’s role
  • {{user.[ID]}} — the user’s data source entry ID

Require syntax

Required fields (string) — the query must include these columns:

"require": ["Email", "FirstName", "Department"]

Field conditions (object with operator) — the query must match these conditions:

"require": [
  { "Email": { "equals": "{{user.[Email]}}" } },
  { "Status": { "notequals": "Archived" } },
  { "Department": { "contains": "{{user.[Department]}}" } }
]

You can mix both formats in the same array:

"require": [
  "Title",
  "Description",
  { "CreatedBy": { "equals": "{{user.[Email]}}" } },
  { "Status": { "equals": "Draft" } }
]

For full examples of require in practice — including sample data, succeed queries, and fail queries — see role-based access with protected fields and department-scoped access above.

Custom security rules

For advanced logic beyond what the standard rule properties support, you can write custom JavaScript security rules.

Custom security

The custom security rule editor in Fliplet Studio displays a code editor where you write JavaScript that controls access. The rule name and enabled toggle appear above the code area.

Important: When a rule has a script, the script is the sole determinant of access. Standard rule fields like allow and type are ignored — the script runs regardless of login status or operation type. Your script must perform its own identity and operation checks (e.g., if (!user) return { granted: false };). If the script does not return a value (e.g., an unhandled operation type falls through without a return), access is denied by default.

When writing a custom rule, these variables are available in the script context:

  • type (String) — the operation the user is attempting: select, insert, update, or delete
  • user (Object) — the authenticated user’s session data, or undefined if not logged in. For data-source passport sessions (e.g., Email Verification, Fliplet Login), this contains the flat column values from the user’s row in the authentication data source (e.g., user.Email, user.Role). For SAML2 sessions, it contains the assertion attributes.
  • session (Object) — server-verified login context with loggedIn (Boolean), passports (Array of passport type names), dataSourceId and entryId (Numbers when the current identity is a data source login). For Studio preview, it is scoped to the previewed app. Guard missing values and bind session.dataSourceId to the intended login source when access depends on that source. This is not the client-writable session data from Fliplet.Session.set().
  • query (Object or Array) — legacy request context. A truthy submitted where takes precedence over operation data and the request body. It does not prove that the route will apply those fields as a filter or write them as data. Do not use it to authorize constrained reads or writes: without where, arbitrary body fields can look like a valid filter even though the route reads without that filter.
  • filter (Object or undefined) — a deep copy of the filter supplied by the query route at access validation. Use it to validate constrained reads. Routes that do not supply a filter, including direct reads by entry ID, leave it undefined; deny those paths when the rule requires a filter. Its values are client-controlled. Changing this copy does not rewrite the request. It describes the filter at validation, not the final database query after hooks; do not attach hooks that broaden or replace filters when relying on these examples.
  • payload (Object, Array or undefined) — a deep copy of the operation data selected by the server for access validation. For individual inserts and updates it contains the proposed column values; query-based updates provide the update data separately from the filter. Commit inserts and updates provide arrays of flat column objects. Other operations may omit it; validate its presence and shape before use. Submitted values remain client-controlled. The copy is mutable, but changing it does not rewrite the operation or another rule’s payload. It reflects data at access validation, which can differ from the original request and from the final stored row after exclusions or hooks.
  • entry (Object or undefined) — the existing entry for individual updates and each row of a query-based update, with id (Number) and data (Object containing current column values). Commit updates do not provide a single entry; do not infer stored ownership from their submitted payload arrays.
  • DataSources (Function) — server-side library for reading data from other data sources. See Reading data from other Data Sources

Preview: The filter and payload contexts require the pending API release. Deploy rules using them only after that support is available in your region. Guard both with typeof checks to deny unsupported reads and writes on older APIs; never fall back to query.

For ownership checks on updates, compare authenticated identity with the existing entry.data, then validate the proposed payload. A submitted owner value does not prove ownership of the stored record. See the complete authorized AI tool example for a login-source binding and a narrow update rule.

This rule permits department-filtered reads, inserts with exactly Name and Department, and a Name update to an active record already in the user’s department. Set the login data source ID before use and remove any broader fallback rules. It denies deletes and array payloads. Query-based updates are checked per existing row, so several eligible rows can pass; rejecting arrays does not prohibit every multi-row operation.

const LOGIN_DATA_SOURCE_ID = 123;
const denied = { granted: false };
if (!user || typeof user.Department !== 'string' || !user.Department ||
    !session || !session.loggedIn || session.dataSourceId !== LOGIN_DATA_SOURCE_ID) {
  return denied;
}
if (type === 'select') {
  return { granted: typeof filter !== 'undefined' && !!filter &&
    typeof filter === 'object' && !Array.isArray(filter) &&
    Object.keys(filter).length === 1 && filter.Department === user.Department };
}
if (type !== 'insert' && type !== 'update') return denied;
if (typeof payload === 'undefined' || !payload ||
    typeof payload !== 'object' || Array.isArray(payload) ||
    typeof payload.Name !== 'string' || !payload.Name.trim() || payload.Name.length > 100) {
  return denied;
}
if (type === 'insert') {
  return { granted: Object.keys(payload).length === 2 && payload.Department === user.Department };
}
// Updates cannot change the stored department or activate another record.
return { granted: Object.keys(payload).length === 1 && !!entry && !!entry.data &&
  entry.data.Department === user.Department && entry.data.Active === true };

With fliplet-datasources loaded and a user logged into source 123 with Department: 'Engineering', these calls use a separate records data source (456). Record 789 must already have Department: 'Engineering' and Active: true for its update to succeed.

async function useDepartmentRecords() {
  const connection = await Fliplet.DataSources.connect(456, { offline: false });
  await connection.find({ where: { Department: 'Engineering' } });
  await connection.insert({ Name: 'New hire', Department: 'Engineering' });
  await connection.update(789, { Name: 'Updated name' }, { allowOffline: false });
}
useDepartmentRecords().catch(function(error) {
  console.error('Department operation failed:', error);
});

An insert for another department, an update containing Department or Active, and an update to another department’s stored row are denied. A body containing { where: { Name: 'Allowed' }, Name: 'Changed', Active: true } is also denied: the rule validates every key in payload, regardless of query.

Granting access

Return an object with granted: true to grant access. For reads, return an exclude or include array to restrict returned columns. include does not enforce a write allowlist; validate the exact keys and values in payload for narrow writes:

if (type === 'select') {
  if (!user) {
    return { granted: false };
  }

  // Grant full access to admin users
  if (user.Admin === 'Yes') {
    return { granted: true };
  }

  // Grant access to any other logged-in user, but hide sensitive columns
  return { granted: true, exclude: ['Phone', 'NextOfKin'] };
}

// No further access is granted by this rule to other operation types

Important: You must return an object — bare boolean values (e.g., return true) are not supported and will be treated as a denial. Always use return { granted: true } or return { granted: false }.

Validating submitted filters

Validate the route-supplied filter before granting constrained read access. A plausible value in legacy query does not prove the route will apply it. Mutating filter, query or payload is not a supported way to rewrite the database operation. Do not attach hooks that broaden or replace read filters after access validation.

This read-only rule requires exactly one literal Office filter matching the authenticated user. It denies missing filters, query operators, direct reads by entry ID and other operations. It requires the preview filter context described above and fails closed when that context is unavailable. Keep it as the only rule for this access path, or ensure other rules cannot grant broader access. For additional login-source restrictions, use the trusted session context described above.

if (!user || typeof user.Office !== 'string' || !user.Office ||
    type !== 'select' || typeof filter === 'undefined' || !filter ||
    typeof filter !== 'object' || Array.isArray(filter) ||
    Object.keys(filter).length !== 1 || filter.Office !== user.Office) {
  return { granted: false };
}
return { granted: true };

The app must submit the matching filter. Load fliplet-session and fliplet-datasources, replace 123 with the records data source ID and run after Fliplet is ready. The client value constructs the query; the security rule independently checks it against the authenticated user’s office.

async function readOfficeRecords() {
  const session = await Fliplet.Session.get();
  const account = session && session.entries && session.entries.dataSource;
  const user = account && account.data;
  if (!user || typeof user.Office !== 'string' || !user.Office) {
    throw new Error('Sign in with the app login before reading office records.');
  }
  const connection = await Fliplet.DataSources.connect(123, { offline: false });
  return connection.find({ where: { Office: user.Office } });
}
readOfficeRecords().then(console.log).catch(function(error) {
  console.error('Office records could not be read:', error);
});

For inserts, validate the supplied payload fields and reject unauthorized values. For updates, check the stored entry.data as well as payload. Use a supported server write mechanism when a value must be generated or changed on the server; do not depend on security-script assignment to persist it.

Checking data when committing changes

The data source commit operation evaluates security separately for inserts, updates and deletes. For inserts and updates, payload is an array of flat column objects. A commit update does not provide the existing rows through a single entry, so a rule that needs stored ownership must deny that shape unless it can independently verify the targeted records. Checking submitted owner fields alone is insufficient.

For explicit commit deletion IDs, the server can provide an array of existing row data. Other deletion paths can omit operation data. Do not assume payload is always present or always an array, and do not use query as a substitute.

This insert-only rule accepts a nonempty array of records with exactly Name and the authenticated user’s Department. It denies updates and deletes, which need separate authorization. Set the login source ID and use it only where those operations should remain denied. The rule also accepts an import’s insert batch with the same shape; it does not identify which endpoint submitted the batch.

const LOGIN_DATA_SOURCE_ID = 123;
if (!user || typeof user.Department !== 'string' || !user.Department ||
    !session || !session.loggedIn || session.dataSourceId !== LOGIN_DATA_SOURCE_ID ||
    type !== 'insert' || typeof payload === 'undefined' ||
    !Array.isArray(payload) || !payload.length) {
  return { granted: false };
}
return { granted: payload.every(function(data) {
  return !!data && typeof data === 'object' && !Array.isArray(data) &&
    Object.keys(data).length === 2 && data.Department === user.Department &&
    typeof data.Name === 'string' && !!data.Name.trim() && data.Name.length <= 100;
}) };

For an Engineering user, [{ Name: 'Alice', Department: 'Engineering' }] passes this insert check. An array containing a Marketing row, an extra field such as Role, or a missing name is denied. This validates the insert batch only; other operations in the same commit still require their own authorization. See the commit API for the request structure.

Reading data from other Data Sources

Custom rules can read data from other Data Sources using the find (multiple records) and findOne (single record) methods of the DataSources server-side library. These reads run at server level and bypass all security rules on the target data source.

Cross-data-source lookups add a database round-trip per request and are not cached. Keep queries efficient and use findOne when you only need a single record. Script execution has a 3-second timeout — if the script does not return within 3 seconds, access is denied. Async operations (like DataSources queries) are supported via await.

Connect using the data source ID (number) or name (string):

if (!user) {
  return { granted: false };
}

if (type === 'select') {
  const entry = await DataSources(123).findOne({
    where: {
      Office: user.Office,
      Managers: { $in: user.Manager },
      Country: { $like: '%United Kingdom%' }
    }
  });

  // Allow reading data if the user has a manager in the same office
  if (entry) {
    return { granted: true };
  }
}

if (type === 'insert') {
  const entries = await DataSources('Users').find();

  // Only allow writes as long as there are fewer than 10 entries in the target Data Source
  if (entries && entries.length < 10) {
    return { granted: true };
  }
}

find returns an array of matching entries (empty array if none match). findOne returns a single entry object, or null if no match is found.

Both methods accept the following properties:

  • where (Object) — query filter supporting standard query operators such as $eq, $ne, $like, $iLike, $gt, $gte, $lt, $lte, $in
  • limit (Number, defaults to 100)
  • offset (Number, defaults to 0)

Use DataSources('Name') (data source name) instead of DataSources(123) (ID) in custom scripts. Data source names are preserved during app clone, so name-based lookups continue to work without manual remapping.