Reference
The permission catalog, the guard, and row-level security
Every public model operation contributes a named permission. An application guard checks the catalog before it acts. On Postgres, tenant-scoped models can generate row-level security policies that check the same names on each row.
Verified against s-m-r-t 0.42.4
Three parts, derived from one list
The catalog is the list of permission names the application recognizes. The guard is a function application code calls before it acts. Row-level security is a set of Postgres policies generated from the same list. Each part is optional, and each answers a different question.
- The catalog names operations. It grants nothing on its own: syncing it creates Permission rows and never assigns them to a role.
- The guard decides. It resolves the principal permissions for a tenant and refuses an operation whose slug is missing from the catalog.
- Row-level security re-checks. Policies sit on the table, so a query that never called the guard is still evaluated against the session tenant and permission list. They are generated from your tenant-scoped models rather than read out of the catalog, and they use the same slug scheme.
- Roles connect the two ends. A permission reaches a person through a membership role, a group role, a tenant-level override, or a per-user override — never from the catalog alone.
The catalog is derived from the manifest
Every operation exposed through the generated API, CLI, or MCP surface contributes a slug of the form collection.action, including custom methods. Methods that are not exposed on any surface are not added. syncPermissionCatalog writes the discovered set into the Permission table.
- list and get both normalize to collection.read; create, update, and delete stay separate.
- A surface you do not configure counts as enabled. A CRUD slug is left out only when every surface — api, cli, and mcp — excludes or disables that action.
- A custom action such as publish becomes articles.publish, as long as some surface exposes it.
- A field that declares a readPermission contributes that slug as well.
- Sync is additive: it creates missing rows and updates name, description, and category by slug.
- Sync does not grant anything to a role and does not delete stale permissions.
import { SmrtObject, smrt } from '@happyvertical/smrt-core';
import { syncPermissionCatalog } from '@happyvertical/smrt-users';
import { getSmrtConfig } from '$lib/server/smrt';
@smrt({
api: { include: ['list', 'get', 'create', 'update'] },
collection: 'articles',
mcp: { include: ['publish'] },
tenantScoped: { mode: 'required' }
})
class Article extends SmrtObject {
tenantId = '';
title = '';
async publish() { return true; }
}
// articles.read, articles.create, articles.update, articles.publish
// — and articles.delete, because no cli config was declared and an
// unconfigured surface counts as enabled.
const result = await syncPermissionCatalog(getSmrtConfig('Permission'));
result.created; // slugs written on this runPackages and configuration add to the same list
A catalog entry can come from three sources: the manifest, the users block of smrt.config.ts, and a runtime registration. Runtime registration is how a framework package contributes its own capabilities, and the merged catalog records which source an entry came from.
- registerPermissionDefinitions returns an unregister function, so a definition can be scoped to a bootstrap or a test.
- Field policy uses the runtime path, not the manifest: importing @happyvertical/smrt-fields calls ensureFieldPolicyPermissionsRegistered, which adds fields.policy.manage and fields.policy.personalize with source runtime.
- These two entries declare no Postgres bindings. FieldPolicy is deliberately not tenant-scoped, so policy generation skips its table. The guard alone enforces both permissions.
- Custom entries in smrt.config.ts may declare explicit Postgres bindings; conflicting metadata for one slug throws rather than silently winning.
import { registerPermissionDefinitions } from '@happyvertical/smrt-users';
const unregister = registerPermissionDefinitions([
{
slug: 'invoices.export',
category: 'billing',
name: 'Export Invoices',
description: 'Allows exporting invoices'
}
]);
// The slug is now in catalog.permissions with source 'runtime'
// and is eligible for syncPermissionCatalog() and the guard.
// Call unregister() to remove it again.The guard is for the code you write yourself
assertOperationPermission derives the catalog slug and requires it to exist. Then, it resolves principal permissions in this order: membership role, group role, tenant override, and user override. Call it in form actions, custom endpoints, jobs, and CLI scripts. Use it at each boundary without a generated route.
- It throws OperationPermissionError, which carries status: 403, unless the decision is allowed or you passed onDeny: "return". Mapping that to an HTTP response is the application’s job outside the ready-made handlers.
- A slug that is not in the catalog is refused with reason unknown_permission, so a typo denies rather than passes.
- A call missing either a resolvable user or a resolvable tenant is refused with reason missing_principal.
- checkOperationPermission, hasOperationPermission, and onDeny: "return" give a decision or a boolean instead of an exception.
- For resource-anchored authorization, pass the resource tenant id. Omit membership so the resolver finds it. The resolver refuses a membership from another tenant.
- Both bypasses are allowed by default, and system context is checked first. An operation that requires an explicit grant needs allowSuperAdminBypass: false and allowSystemContextBypass: false.
- Neither bypass comes from a user record. The wrapper sets it with withSystemContext, withSuperAdminBypass, or matching context options. superAdminBypass: true on the session handler publishes the bypass on every request.
import { assertOperationPermission } from '@happyvertical/smrt-users';
import { getSmrtConfig } from '$lib/server/smrt';
export const actions = {
publish: async ({ locals, params }) => {
const article = await loadArticle(params.id);
// Throws OperationPermissionError (status 403) when denied.
await assertOperationPermission({
...getSmrtConfig('Permission'),
collection: 'articles',
action: 'publish',
userId: locals.user.id,
// The resource tenant, not the session tenant.
tenantId: article.tenantId
});
await article.publish();
}
};One context publishes the principal to the database session
withSessionPermissionContext loads the session, resolves its permissions, opens a transaction, and writes the principal onto that transaction with set_config. Generated SvelteKit routes pick that transaction up through the generated getCollection helper, and getRequestScopedDatabase hands it to code you write. The policies then apply to every query on that connection, whichever entry point issued it.
- Six transaction-local settings are published: smrt.tenant_id, smrt.user_id, smrt.session_id, smrt.permissions, smrt.super_admin_bypass, and smrt.system_context.
- smrt.permissions is the resolved slug list as a JSON array; the policies read it as jsonb.
- One request is one transaction. The transaction opens before the route runs and commits after it. An unhandled error rolls back all request writes. Work that outlives the handler runs after the commit and outside the policies.
- The wrapper needs a database adapter that supports beginTransaction. The wrapper throws on the first request that enters the context, not at boot. The session handler catches that error.
- Pass postgresRls to createSessionHandler explicitly. The handler reads its own option, not the config flag, for the two behaviors below.
- With the handler option set, an anonymous request enters the context with an empty permission list. It does not run outside the policies. A context failure returns a bare 500. The catch wraps the complete request, so a later route error becomes the same 500. The permissions.postgres.enabled option alone makes anonymous requests skip the context. A context failure is logged, and the request continues.
- getCollection only reaches for the request transaction when neither the call site nor objectOverrides supplies a db. An object pinned to its own connection runs outside the published variables, where the policies match nothing.
- skipPaths is checked first, so a skipped route never enters the context. On a policy-covered table it will therefore read no rows at all.
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
export const handle = createSessionHandler({
db: { type: 'postgres', url: process.env.DATABASE_URL! },
ttl: 604800,
enterTenantContext: true,
postgresRls: true
});
// event.locals now carries { user, membership, permissions, tenantId, sessionId }
// and the request runs inside a transaction whose session variables the
// generated policies read.What the generated policies check
generatePostgresPermissionSql emits three helper functions. For each covered table, it emits ENABLE and FORCE ROW LEVEL SECURITY. It also emits a drop-and-create policy pair for each action with a bound permission. Apart from the bypass, a policy requires the tenant match and permission, and neither condition alone opens a row. current_setting uses the missing_ok flag, so a connection outside the context gets no tenant and an empty permission list. This connection matches nothing.
- smrt_rls_bypass reads smrt.system_context and smrt.super_admin_bypass, mirroring the two bypasses the guard honors.
- smrt_current_tenant_id reads smrt.tenant_id and returns null when it is unset or empty.
- FORCE ROW LEVEL SECURITY and ENABLE are applied, so the table owner is also subject to the policies.
- Postgres still exempts superusers and any role holding BYPASSRLS. Connect the application as an ordinary role, or the policies are inert.
- SELECT and DELETE use USING; INSERT uses WITH CHECK; UPDATE uses both with the same condition.
CREATE OR REPLACE FUNCTION smrt_has_permission(required_permission text)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT smrt_rls_bypass()
OR jsonb_exists(COALESCE(NULLIF(current_setting('smrt.permissions', true), ''), '[]')::jsonb, required_permission)
$$;
ALTER TABLE "public"."articles" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."articles" FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "smrt_articles_select_44e06f89" ON "public"."articles";
CREATE POLICY "smrt_articles_select_44e06f89" ON "public"."articles"
FOR SELECT USING (
smrt_rls_bypass()
OR (("tenant_id"::text = smrt_current_tenant_id())
AND (smrt_has_permission('articles.read')))
);Enable row-level security in order
Installing the policies and publishing the principal are two separate switches, and neither enforces anything alone. applyPostgresPermissionPolicies installs the policies; permissions.postgres.enabled or a postgresRls option makes requests publish the session variables those policies read. Generation reads the in-process object registry rather than the database, so the script has to import the module that registers your models first.
- Import your model registration before generating. A script that loads only smrt-users produces zero targets and zero skips, applies the three helper functions, and exits cleanly without covering anything.
- Seed the grants between syncing and applying. Sync creates permission rows and assigns none, so policies applied before any role holds the new slugs take every covered table to zero rows. The default matrix gives owner and admin every catalog permission.
- Preview and read result.skipped. A table is skipped for an object without required tenant scope. A missing schema table name or multiple objects on one table also causes a skip.
- applyPostgresPermissionPolicies refuses a connection it does not detect as Postgres. It is not transactional: statements run one at a time, so a failure part way through leaves partial state — fix the cause and re-run.
- Re-running is safe for tables still in the target set, because each policy is dropped and recreated. A table that leaves the set keeps its old policies and its forced RLS; drop those by hand.
- The automatic action mapping is SELECT/INSERT/UPDATE/DELETE to read/create/update/delete. A custom permission requires an explicit Postgres binding. A binding also forces RLS on that table. Bind each necessary action, or an unbound action becomes deny-only.
- Verify the result. Select tablename, policyname from pg_policies where policyname like 'smrt_%'. Confirm that the application role is not a superuser or BYPASSRLS role.
import {
applyPostgresPermissionPolicies,
generatePostgresPermissionSql,
RoleCollection,
syncPermissionCatalog
} from '@happyvertical/smrt-users';
// Registers your models in the object registry. Without this the
// generator sees no tables and applies nothing.
import '$lib/server/smrt-register';
const db = { db: { type: 'postgres' as const, url: process.env.DATABASE_URL! } };
await syncPermissionCatalog(db);
const roles = await RoleCollection.create(db);
await roles.seedSystemRoles({ seedPermissions: true });
const preview = generatePostgresPermissionSql(db);
console.log(preview.targets); // tables that will be covered
console.log(preview.skipped); // and why the rest were not
await applyPostgresPermissionPolicies(db);Where the two layers see different things
The guard and the policies read the same permission list but scope it differently. Which layer answers a given request determines what is actually enforced.
- Row filtering follows the session tenant. The guard can authorize a root-tenant session for a child tenant row when you pass the resource tenant id. Row-level security does not authorize this relationship. On a covered table, the operation is permitted but the row stays invisible. The session must switch to the child tenant, which rotates the session id.
- A session whose tenant is the child tenant does get inherited authority in the policies, because the permission list is resolved before it is published.
- A skipped table has no policies at all. For those tables the guard is the only check, so treat result.skipped as a list of places application code has to carry.
- The bypass helpers are shared. An operation that must resist a super-admin needs allowSuperAdminBypass: false in the guard. It also needs an explicit data-layer design decision.
Applications that do not run Postgres
Row-level security is a Postgres feature, and this framework has no SQLite equivalent. On SQLite, the catalog and guard operate without changes. Permissions still resolve, and the context still carries the principal. The data layer enforces nothing. Thus, the guard is the complete boundary.
- applyPostgresPermissionPolicies throws on a non-Postgres connection.
- On a non-Postgres connection, postgresRls runs the request without the transaction or session variables. The request does not fail. Thus, the flag does not prove that policies are active.
- Pass the published set as permissionSet. The guard then uses the same snapshot that the policies would read, instead of resolving permissions again during the request.
- A development database on SQLite and a production database on Postgres therefore differ in enforcement, not only in performance. Cover permission behavior with tests that exercise the guard directly.
import {
assertOperationPermission,
getCurrentSessionPermissionContext
} from '@happyvertical/smrt-users';
export async function guardToolCall(action: string) {
const context = getCurrentSessionPermissionContext();
await assertOperationPermission({
collection: 'articles',
action,
userId: context?.userId,
tenantId: context?.tenantId,
// Authorize against the set this context published rather than
// re-resolving, so SQLite and Postgres agree on the answer.
permissionSet: context?.permissionSet
});
}