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 ownership
  • org_id: Links the resource to an organization for team-level visibility
  • visibility: Controls public access levels (typically private, org, or public)

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 resource
  • principal_type: Either user or org
  • principal_id: The identifier of the user or organization
  • role: The granted permission level (viewer, editor, or admin)
  • created_by and created_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_email matches requester)
  • Organizational visibility (org_id matches and visibility equals org)
  • Public visibility (visibility equals public)
  • 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 add owner_email, org_id, and visibility columns
  • Generate a shares table using createSharesTable() to store role-based grants for users and organizations
  • Filter reads with accessFilter(), which constructs an EXISTS sub-query combining ownership, visibility, and share checks
  • Validate writes with assertAccess() or resolveAccess() using the numeric ROLE_RANK hierarchy (viewer=1 to owner=4)
  • Reference packages/core/src/sharing/schema.ts for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →