Implementing Sharing and Access Control with ownableColumns in agent-native
To implement sharing and access control in agent-native, spread ownableColumns() into your Drizzle table definition and pair it with createSharesTable() to generate a companion shares table, then use accessFilter() for reads and assertAccess() for writes.
The BuilderIO/agent-native framework standardizes resource ownership through a centralized sharing architecture. By implementing sharing and access control with ownableColumns in agent-native, you can convert any database table into an ownable resource that supports fine-grained grants, organizational visibility, and role-based permissions without custom access logic.
Database Schema Setup with ownableColumns
The sharing system builds on two factory functions exported from @agent-native/core/sharing that establish the database schema.
Adding Ownership Columns to Resources
The ownableColumns() factory, defined in packages/core/src/sharing/schema.ts, adds three critical columns to your Drizzle table definition:
owner_email: Stores the email of the resource creator as the source of truth for true ownershiporg_id: Links the resource to an organization for team-level visibilityvisibility: Controls public access levels (typicallyprivate,org, orpublic)
Spread the result of ownableColumns() into your table definition to make the resource ownable:
import { table, text, ownableColumns } from '@agent-native/core/db/schema';
export const decks = table('decks', {
id: text('id').primaryKey(),
title: text('title').notNull(),
data: text('data').notNull(),
// Spread ownable columns into the table definition
...ownableColumns(),
});
Creating the Shares Table
The createSharesTable(name) factory generates a companion table that records per-principal grants. This table stores:
resource_id: Foreign key referencing the parent ownable resourceprincipal_type: Eitheruserororgprincipal_id: The identifier of the user or organizationrole: The granted permission level (viewer,editor, oradmin)created_byandcreated_at: Audit metadata
import { createSharesTable } from '@agent-native/core/db/schema';
export const deckShares = createSharesTable('deck_shares');
The shares table references the parent resource via resource_id, enabling the accessFilter helper to construct EXISTS sub-queries that verify grants without leaving the SQL context.
Access Control Flow and Role Hierarchy
The agent-native runtime enforces permissions through a consistent flow that handles both write-time ownership injection and read-time access filtering.
Write-Time Owner Injection
When creating resources, the framework automatically populates ownership fields from the request context. Define your creation actions to accept the authenticated user's email and selected organization ID:
export const createDeck = defineAction('create-deck', async (ctx, input) => {
const { email, orgId } = ctx.requestContext;
return db.insert(decks).values({
id: nanoid(),
title: input.title,
data: input.data,
owner_email: email,
org_id: orgId,
visibility: 'private',
});
});
Read-Time Permission Filtering
For queries, use the accessFilter(parentTable, sharesTable, ...) helper exported from @agent-native/core/sharing. This function returns a Drizzle WHERE clause that filters rows based on:
- Direct ownership (
owner_emailmatches requester) - Organizational visibility (
org_idmatches andvisibilityequalsorg) - Public visibility (
visibilityequalspublic) - Explicit share grants (where shares table contains a qualifying row)
Internally, accessFilter constructs an EXISTS sub-query against the shares table, keeping the operation as a single SQL statement for performance.
import { accessFilter } from '@agent-native/core/sharing';
export const listDecks = defineAction('list-decks', async (ctx) => {
const where = accessFilter(decks, deckShares);
return db.select().from(decks).where(where);
});
Role Hierarchy and Validation
Permission levels follow a numeric hierarchy defined in packages/core/src/sharing/schema.ts as ROLE_RANK:
| Role | Rank |
|---|---|
| viewer | 1 |
| editor | 2 |
| admin | 3 |
| owner | 4 |
The roleSatisfies(actual, minimum) function compares these ranks to determine if a user holds adequate permissions. For mutations, use assertAccess() to enforce requirements before execution:
import { assertAccess } from '@agent-native/core/sharing';
export const updateDeck = defineAction('update-deck', async (ctx, { deckId, data }) => {
await assertAccess(decks, deckShares, deckId, ctx, 'admin');
return db.update(decks).set(data).where(eq(decks.id, deckId));
});
Additional helpers like resolveAccess() and currentAccess() enable the UI layer to conditionally render controls based on the resolved permission level.
Practical Implementation Examples
The following patterns demonstrate the complete lifecycle of an ownable resource, from schema definition to sharing.
Defining an Ownable Resource and Shares
Combine the schema factories to establish both the parent table and its shares companion:
import {
table,
text,
now,
ownableColumns,
createSharesTable,
} from '@agent-native/core/db/schema';
export const decks = table('decks', {
id: text('id').primaryKey(),
title: text('title').notNull(),
data: text('data').notNull(),
...ownableColumns(),
});
export const deckShares = createSharesTable('deck_shares');
Querying with Access Control
Always apply accessFilter to list operations to ensure users only see resources they own, share, or have organizational access to:
import { accessFilter } from '@agent-native/core/sharing';
import { decks, deckShares } from '@/db/schema';
export const listDecks = defineAction('list-decks', async (ctx) => {
const where = accessFilter(decks, deckShares);
return db.select().from(decks).where(where);
});
Granting Share Permissions
To share a resource, verify the current user has admin or higher access, then insert a row into the shares table:
import { assertAccess } from '@agent-native/core/sharing';
export const shareDeck = defineAction('share-deck', async (ctx, { deckId, principal, role }) => {
await assertAccess(decks, deckShares, deckId, ctx, 'admin');
return db.insert(deckShares).values({
id: nanoid(),
resource_id: deckId,
principal_type: principal.type,
principal_id: principal.id,
role,
created_by: ctx.requestContext.email,
created_at: now(),
});
});
Summary
Implementing sharing and access control with ownableColumns in agent-native relies on a consistent pattern across the database, action, and UI layers:
- Spread
ownableColumns()into any Drizzle table to addowner_email,org_id, andvisibilitycolumns - Generate a shares table using
createSharesTable()to store role-based grants for users and organizations - Filter reads with
accessFilter(), which constructs anEXISTSsub-query combining ownership, visibility, and share checks - Validate writes with
assertAccess()orresolveAccess()using the numericROLE_RANKhierarchy (viewer=1 to owner=4) - Reference
packages/core/src/sharing/schema.tsfor all type definitions, role ranks, and factory implementations
Frequently Asked Questions
What columns does the ownableColumns factory add to a table?
According to packages/core/src/sharing/schema.ts, ownableColumns() adds three columns: owner_email (text, not null) which stores the true owner's email address, org_id (text) linking the resource to an organization, and visibility (text) controlling access levels such as private, org, or public.
How does accessFilter determine which rows a user can see?
The accessFilter helper constructs a Drizzle WHERE clause that returns rows where the user is the owner, the resource is visible to their organization, the resource is public, or an entry exists in the shares table granting at least the required role. It implements this check as an SQL EXISTS sub-query against the shares table to maintain single-statement performance.
What is the difference between assertAccess and resolveAccess?
assertAccess() throws an authorization error if the caller lacks the minimum required role, making it ideal for mutation actions that need to fail fast. resolveAccess() returns the resolved role string (or null) without throwing, allowing UI logic to conditionally render edit controls or share management interfaces based on the user's actual permissions.
Can I use createSharesTable with any Drizzle table definition?
Yes, createSharesTable(name) is designed to work with any table that includes the columns from ownableColumns(). The factory creates a standard Drizzle table definition referencing a parent resource via resource_id, meaning it functions across all SQL dialects supported by Drizzle and integrates with the registry system defined in packages/core/src/sharing/registry.ts.
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 →