How to Implement Data Scoping Using owner_email and org_id in Agent-Native
Agent-Native implements a dual-key data scoping mechanism using owner_email and org_id columns to enforce row-level security across personal and organizational data boundaries.
The BuilderIO/agent-native repository provides a robust multi-tenant architecture that isolates user data through a dual-key scoping pattern. By combining owner_email and org_id columns on every tenant-scoped table, the system ensures that personal data remains separate from organizational resources while supporting complex sharing scenarios.
Understanding the Dual-Key Scoping Model
Agent-Native stores every user-owned record with two scoping columns that work together to create a composite access key:
owner_email– Atext NOT NULLcolumn containing the email of the authenticated user who created the row. This provides a stable, privacy-preserving identifier that survives organization changes.org_id– Atextnullable column containing the identifier of the organization the row belongs to, if any. This enables true multi-tenant isolation.
These columns are defined in the database schema located at templates/slides/server/db/schema.ts. Together they create a dual-key model that satisfies three distinct data ownership patterns:
- Personal data – Rows where
org_idisnull. - Org-scoped data – Rows with a non-null
org_idvalue. - Guest/public data – Rows owned by a guest account email with
org_idset tonull.
Accessing Request Context
When a Nitro request is handled, the runtime populates a request context containing the current user's email and active organization ID. Import these values using the context helpers from @agent-native/core/server/request-context:
import { getRequestUserEmail, getRequestOrgId } from "@agent-native/core/server/request-context";
The getRequestUserEmail() function returns the email of the signed-in user or throws an error if the request is unauthenticated. The getRequestOrgId() function returns the organization ID resolved from the session, the live organization context, or null for guest users.
The context is injected by the withSlidesRequestContext and withPlanRequestContext helpers defined in templates/slides/server/handlers/request-auth-context.ts.
Writing Data with Scoping Columns
All write actions must explicitly include both scoping columns to prevent orphan rows and ensure proper access control. Here is how the create-deck action in templates/slides/actions/create-deck.ts tags new records:
const ownerEmail = getRequestUserEmail();
const orgId = getRequestOrgId();
await db.insert(schema.decks).values({
title,
ownerEmail,
orgId, // null for personal decks, org id for organization-wide decks
// ...other fields...
});
If the user is not authenticated, getRequestUserEmail() throws an error, preventing the creation of unowned data. This pattern ensures that every row carries the complete scoping context necessary for future access control decisions.
Reading Data with Scoped Queries
Every list and read action must filter results using both scoping predicates to enforce the dual-key isolation. The list-decks action in templates/slides/actions/list-decks.ts demonstrates this pattern using Drizzle ORM's and() and eq() operators:
const ownerEmail = getRequestUserEmail();
const orgId = getRequestOrgId();
const decks = await db.select()
.from(schema.decks)
.where(and(
eq(schema.decks.ownerEmail, ownerEmail),
eq(schema.decks.orgId, orgId) // matches null when the user has no org
));
This query ensures that users only see decks that match both their email and current organization context. When orgId is null, the query correctly filters for personal data only. To retrieve personal decks specifically, omit the organization filter, though the default implementation always scopes to the current organization context.
Enforcing Cross-Resource Consistency
When moving resources between containers, Agent-Native verifies that both source and target resources belong to the same organization to prevent data leaks across tenant boundaries. The move-composition-to-folder action in templates/videos/actions/move-composition-to-folder.ts implements this guard:
if (compositionAccess.resource.orgId &&
folderAccess.resource.orgId &&
compositionAccess.resource.orgId !== folderAccess.resource.orgId) {
throw new Error("Cannot move across org boundaries");
}
This validation ensures that cross-resource operations maintain the integrity of the scoping model and prevent accidental exposure of organizational data to unauthorized users.
Handling Guest and Public Data
Guest-only rows use null values for both the organization ID and a specific guest email identifier. The sharing-access matrix in templates/plan/server/sharing-access-matrix.spec.ts contains logic that treats a null organization ID as "public-within-the-owner-email" scope.
The system includes a null-guard test that ensures guest identities never carry a non-null organization ID. This prevents guest users from accessing or creating organization-scoped data while allowing them to own and share personal resources within their limited access scope.
Summary
- Dual-key scoping relies on
owner_emailandorg_idcolumns to isolate data between personal and organizational contexts. - Request context helpers
getRequestUserEmail()andgetRequestOrgId()provide the current user's scoping keys for every operation. - Write operations must explicitly include both columns to prevent orphan rows and ensure future queryability.
- Read queries use
and(eq(ownerEmail, ...), eq(orgId, ...))to enforce row-level security at the database level. - Cross-resource moves validate matching
org_idvalues to prevent data leakage across organizational boundaries. - Guest accounts operate with
nullorganization IDs, limiting them to personal data scopes only.
Frequently Asked Questions
What happens if a user is not authenticated when accessing data?
The getRequestUserEmail() function throws an error if the request lacks authentication, preventing any data access or modification. This ensures that all database operations occur within a valid user context, eliminating the risk of unscoped data leakage to anonymous users.
How does Agent-Native handle moving data between different organizations?
The system explicitly blocks cross-organization moves by comparing the org_id values of both source and target resources. If the organization IDs differ, the operation throws an error with the message "Cannot move across org boundaries", maintaining strict tenant isolation even during complex data management operations.
Can a single user own both personal and organization-scoped data simultaneously?
Yes. Users can own personal data where org_id is null and organization-scoped data where org_id matches their active organization. The request context determines which scope is active during a given operation, and queries can be constructed to return data from one scope or both depending on the use case.
Why does Agent-Native use owner_email instead of a numeric user ID?
The owner_email column provides a stable, privacy-preserving identifier that remains constant even if the user's underlying system ID changes or if they move between organizations. This approach ensures that data ownership remains traceable and consistent across organizational changes without exposing internal database identifiers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →