Quickstart: Lock Down the Enterprise
Configure visual roles, define collection access, inject RLS, set field redaction, monitor audit logs, handle multi-tenant isolation — the full setup in 6 steps, in under 25 minutes.
Configure the Visual Role Manager
| Role | Permission set |
|---|---|
| **Super Admin** | `*:*` — full access (use sparingly) |
| **Tenant Admin** | `*:*` within own tenant; no cross-tenant access |
| **Finance Lead** | `finance:*`, `billing:*`, `commerce:read` |
| **Sales Manager** | `crm:*`, `commerce:read`, `marketing:read`, `users:read` |
| **Sales Rep** | `crm:read`, `crm:write` (own contacts), `commerce:read` |
| **Support Agent** | `helpdesk:*`, `crm:read`, `commerce:read`, `users:read` |
| **Customer** | `account:*` (own only), `commerce:read` (own orders) |
| **Viewer** | `*:read` — read-only across modules |
Define Base Collection Access
Open your module's source code + locate the Collection Schema. Define baseline rules:
```typescript import { createAccessControl, createScopedReadAccess, hasAnyRole, } from "@aeion/core/schema/security/accessControlHelpers"; import type { CollectionConfig } from "@aeion/types/schema";
export const Warehouses: CollectionConfig = { slug: "scm_warehouses", access: createAccessControl({ // Who can create new warehouse records? create: ["admin", "scm-manager"],
// Who can update existing warehouses? // "owner-or-admin" — record creator OR admin can update update: "owner-or-admin",
// Who can delete warehouses? delete: "admin-only",
// Who can read? (next step adds RLS) read: "authenticated", // Any logged-in user (refined below) }), fields: [ { name: "name", type: "text" }, { name: "address", type: "text" }, { name: "manager", type: "relationship", relationTo: "users" }, // ... more fields ... ], }; ```
Built-in access keywords:
"public"— anyone (including unauthenticated)"authenticated"— any logged-in user"admin-only"— admin or super-admin"owner-or-admin"— record owner or admin"tenant-admin-or-admin"— tenant admin or super admin
Custom role arrays: ["role-a", "role-b", "role-c"] — any of these roles can perform the action.
Defense: if a role isn't authorized, the operation returns 403 Forbidden. No data leaks; no error messages reveal why.
Inject Row-Level Security (RLS)
Standard access keywords cover most cases. For per-user record filtering, use createScopedReadAccess:
```typescript import { createScopedReadAccess, hasAnyRole, } from "@aeion/core/schema/security/accessControlHelpers";
export const Warehouses: CollectionConfig = { // ... access: createAccessControl({ create: ["admin", "scm-manager"], update: "owner-or-admin", delete: "admin-only",
// ROW-LEVEL SECURITY: standard staff only see warehouses for their facility read: createScopedReadAccess(async (ctx, user) => { if (hasAnyRole(user, ["admin", "scm-manager"])) { return true; // Admins + managers see all warehouses } // Standard staff: only see warehouses where they're assigned return { OR: [ { manager: { equals: user.id } }, { teamMembers: { contains: user.id } }, ], }; }), }), }; ```
The Filter object returned by createScopedReadAccess is automatically injected into every SQL query for this collection. SQL execution doesn't return rows the user shouldn't see — the database literally can't return them.
Performance: the Filter compiles to standard WHERE clauses. Use database indexes on the columns referenced (e.g., index manager, teamMembers) for optimal performance.
Multi-condition Filters: support full AST — AND, OR, NOT, nested conditions, comparison operators, IN/NOT IN, etc.
Async logic supported — the RLS function can be async (e.g., fetch the user's team from another table). Use sparingly; runs on every query.
Set Field-Level Redaction
Some fields shouldn't be visible to all users who can read the record. Define field-level access:
typescript
export const Invoices: CollectionConfig = {
slug: "finance_invoices",
access: createAccessControl({
create: ["admin", "accountant"],
delete: "admin-only",
read: createScopedReadAccess(async (ctx, user) => {
if (hasAnyRole(user, ["admin", "finance-manager"])) return true;
return { generatedById: { equals: user.id } };
}),
}),
fields: [
{ name: "invoiceNumber", type: "text" },
{ name: "customer", type: "relationship", relationTo: "customers" },
{ name: "totalAmount", type: "number" },
{
name: "profitMargin",
type: "number",
access: {
// FIELD-LEVEL REDACTION: only finance-manager + admin see this
read: ({ user }) => hasAnyRole(user, ["admin", "finance-manager"]),
},
},
{
name: "internalNotes",
type: "richText",
access: {
// Internal notes only visible to authorized roles
read: ({ user }) => hasAnyRole(user, ["admin", "finance-team"]),
},
},
],
};
How it works:
- Standard user queries
finance_invoices→ row returned withprofitMargin+internalNotesSTRIPPED from the JSON payload - The user can see they exist on the record (field name in the schema) but the value is
undefined - No 403 errors — graceful redaction so the UI can adapt
Override at runtime — admin tools can request unredacted data with ?includeSensitive=true + appropriate auth. Audit log captures these requests.
Monitor Audit Logs
Every access attempt — successful or blocked — logs to the audit log.
Per-row audit fields:
- Actor identity (user / service account / API key)
- Action attempted
- Resource (collection + record ID)
- Outcome (allowed / blocked / error)
- If blocked: which RBAC rule rejected it
- Field-level access (which fields were redacted from the response)
- IP / device fingerprint
- HTTP method + endpoint
- Latency + result size
Query the audit log:
```typescript // "Who attempted to view customer ct_xyz in the last 24 hours?" // The audit log is a regular collection — query via the standard service registry. const attempts = await ctx.services["audit_log"].list({ ctx, where: { resourceCollection: { equals: "contacts" }, resourceId: { equals: "ct_xyz" }, createdAt: { newer_than: "24h" }, }, limit: 100, });
// "Show all blocked-by-RBAC attempts last 7 days" const blocked = await ctx.services["audit_log"].list({ ctx, where: { outcome: { equals: "blocked" }, createdAt: { newer_than: "7d" }, }, }); ```
Audit-log dashboards — Admin → Security → Audit Logs surfaces:
- Top blocked-attempt sources (IP / user) — potential probing
- Failed login + suspicious password spray attempts
- Mass-export attempts that exceed normal patterns
- Off-hours access by users not normally active off-hours
- Real-time stream filtering by action type / resource / outcome
SIEM export — push to Splunk / DataDog / Sumo Logic / Elastic / your SIEM of choice via the streaming audit connector.
Handle Multi-Tenant Isolation
Multi-tenant isolation is automatic + sits BENEATH the RBAC layer.
How tenant isolation works:
HTTP Request → Auth middleware → ctx.tenant.id resolved
↓
Tenant-scoped DB client
↓
All queries auto-injected
tenantId = ctx.tenant.id
↓
RBAC layer evaluates
↓
Field-level access applies
↓
Audit log records the operation
Cross-tenant access (Aeion's safest mechanism):
- Default
ctx.dbis tenant-scoped — cannot access cross-tenant data ctx.sysDb— separate client for platform operations onlysysDbonly available inside platform-tenant code paths- Module-level code can't import
sysDb(TypeScript types enforce the boundary)
Cross-tenant operations (e.g., platform admin viewing tenant health) use the sysDb field that the kernel attaches to platform-context requests. It's a raw Drizzle client distinct from ctx.db; module route handlers never receive it.
typescript
// Inside platform-tenant route handlers only — ctx.sysDb is populated
// by the kernel when the request is running in platform-admin context.
const allTenantsStatus = await ctx.sysDb.query.tenants.findMany({
where: { status: "active" },
});
Audit trail — every cross-tenant operation is tagged with platform-level flag in the audit log for distinct compliance reporting (vs tenant-level operations).
Tenant scoping at query level — even if a developer mistakenly uses raw SQL in a tenant context, the wrapper detects the missing tenant_id filter + adds it.
Integration Recipes
RBAC + Database Wrapper. RBAC rules are evaluated by the database wrapper before SQL execution. Defense-in-depth — the database itself never returns unauthorized rows.
RBAC + Audit Log. Every blocked attempt logs to the audit log. Useful for detecting probing behavior + understanding actual permission boundaries vs nominal permissions.
Visual Role Manager + UI auto-hide. When a user lacks a permission, UI components auto-hide. Less confusion ("why is this button greyed out?"); cleaner UX per role.
RBAC + Multi-tenant. Tenant isolation operates underneath RBAC. An admin in tenant A can never see tenant B's data, regardless of RBAC config. Configurable cross-tenant access is a separate privilege class.
RBAC + Compliance Profiles. GDPR-strict mode + PCI mode add additional RBAC layers on top — sensitive data restricted to attested users even within the standard RBAC permission set.
Troubleshooting
User can't see records they should be able to. Three layers to check: (1) base collection access, (2) RLS filter, (3) field-level access. Use Admin → Inspect → User → [user] → Permission Trace to see exactly which rule is blocking.
'Forbidden' on operations that should work. Common cause: missing permission. Add the appropriate module:action tuple to the user's role. Or use wildcard if the user needs broader access.
RLS query slow. RLS injects WHERE clauses; add database indexes on the columns referenced. Use EXPLAIN ANALYZE to verify the planner uses the indexes.
Visual Role Manager not updating user permissions. Permission caches updated on role change but propagate eventually. User logout/login forces immediate refresh. Or use Admin → Users → [user] → Recompute Permissions.
Field redaction not applying. Field-level access runs after row access. If the row itself is blocked by RLS, you won't see the redaction. Test row access first; then field access separately.
Cross-tenant data accidentally leaking. This should be impossible by design. If you see it, file a security incident immediately. Likely caused by a developer using sysDb outside platform-tenant code paths. Audit log shows the source.
Frequently Asked Questions
You don't have to write code for it. Multi-tenant isolation operates beneath the RBAC layer. The wrapper forcefully injects `tenantId = ctx.tenant.id` into every query BEFORE RBAC RLS is evaluated. Defense-in-depth automatic.
No. The Visual Airlock hides UI React Components; backend 3-Stage Interceptors (Pre-Query, Post-Read, Pre-Write) are the final source of truth. Even a manual `curl` request to `/api/v1/finance` is rejected by the backend RBAC engine — JWT lacks `finance:read`, returns 403.
Yes. The `hasPermission()` utility resolves wildcards gracefully. `commerce:*` clears the user for `commerce:read`, `commerce:pricing`, `commerce:orders`, etc.
Yes. `createScopedReadAccess` accepts async functions — fetch additional context, evaluate dynamic conditions. Examples: "users can edit their own records", "managers can see their team's data", "during business hours only".
Most-permissive resolution by default — union of all roles' permissions. Configurable per-tenant to use most-restrictive (intersection) for higher security environments.
Yes — use a Filter in RLS that includes the specific IDs. Example: `read: createScopedReadAccess(() => ({ id: { in: ["rec_xyz", "rec_abc"] } }))`. Or use record-level ACL stored as a `permissions` field on the record itself.
API keys are tied to a user (or service account) + can be scoped to specific permissions. Key creation requires the user to confirm which permissions to grant — keys can't have permissions the user doesn't have themselves.
Marginal. Pre-write checks are O(1) — just hash lookup of role + permission. RLS injection happens at SQL compile time, not runtime — no per-row evaluation. Field-level redaction strips fields from the response object — negligible CPU cost.
Yes — "delegated permissions" let a user explicitly request elevated access for a specific time window + reason. Logged in audit + auto-revoked at expiration. Used for emergency response or specific one-off operations.
Yes — RBAC + Filter-based RLS combined approximates ABAC. Filter functions can read any attribute (user properties, record properties, environment context) + make dynamic decisions. Pure ABAC is a hairy beast; Aeion's RBAC + RLS gives 95% of the value without the complexity.