Identity is two questions. Authentication asks who you are; authorization asks what you may touch. We answer the first with open standards and an identity provider we can swap, and the second inside our own apps, where the data lives. Every app is multitenant from its first migration, because adding tenants later means touching every query.
An organization is a customer: a dealer group, a franchise, a company. Inside it sit rooftops, the physical locations whose data is kept apart, and groups, which collect rooftops (a region, a brand) and can contain other groups. People join an organization through a membership, and what they can do comes from grants: a role on a group or a rooftop.
Organization ─┬─ Group "West" ─┬─ Rooftop "Phoenix"
│ └─ Rooftop "Tucson"
└─ Group "East" ─── Rooftop "Austin"
User ── Membership(org) ──┬─ Grant(role: :manager, on: Group "West")
└─ Grant(role: :viewer, on: Rooftop "Austin")
A grant on a group covers every rooftop beneath it, so a regional manager is one row, not twenty. An owner role on the organization itself covers everything. That is the whole model: four tables plus the user.
organizations id, name, slug
units id, organization_id, parent_id, kind (group|rooftop), name, path
memberships id, organization_id, user_id, status
grants id, membership_id, unit_id (null = whole org), role
Groups and rooftops share one units table with a materialized path
(/1/4/9/), so "every rooftop this person can see" is one indexed LIKE query instead
of a recursive walk. Every business record carries organization_id and rooftop_id.
Current.organization and the set of visible rooftop ids.Current.organization.deals.where(rooftop: visible_rooftops)),
never through a bare Deal.find. A Rails model concern makes the scoped path the easy one.app.organization_id context. An unscoped query must not expose another tenant's rows;
it may still return rows allowed for the current tenant. Reject missing or invalid tenant context or deny all
tenant rows, and verify that behavior rather than assuming it from an RLS flag.PostgreSQL 18 documentation, checked October 16, 2026; deployed version and configuration unverified.
Enabling RLS ordinarily leaves the table owner exempt.
FORCE ROW LEVEL SECURITY subjects an otherwise non-bypass owner to policy, but superusers and
BYPASSRLS roles remain exempt: FORCE is not a universal bypass override.
Our design recommendation is a non-owner, non-superuser, non-bypass runtime role, separate from migration ownership. Review inherited privileges and permitted SET ROLE paths, not just the immediate role's flags. Check request, job and export connections against the expected tenant-table list; a missing table must fail the check. Derive tenant context from trusted authorization and test its lifecycle across transactions, pool reuse and retries. A tenant setting is not itself an authorization decision.
Test indirect paths too. Ordinary views
use their owner's base-relation permissions and RLS policies; security_invoker=true instead uses the caller's.
CURRENT_USER in a view is not proof of that permission identity.
SECURITY DEFINER functions run with owner privileges.
A passing direct-table test does not establish wrapper isolation; review privileged helpers separately.
Acceptance experiments—proposed, not run: in a separately authorized disposable PostgreSQL 18 fixture, use synthetic A/B tenants. With valid A context, an unscoped read may return allowed A rows but no B rows; B-targeted updates/deletes affect zero rows, while cross-tenant inserts and retagging fail policy checks. Missing or invalid context must reject the operation or deny all tenant rows under the application's documented contract. Repeat through jobs, exports, views/functions, retries and connection reuse; retain sibling-rooftop authorization tests separately.
Include deliberately unsafe negative controls: an owner without FORCE, a BYPASSRLS role with table privileges, and a wrapper owned by that exempt table owner should expose the fixture's isolation failure. An ordinary owner with FORCE and an applicable tenant policy should be constrained. These checks are a proposed test design, not evidence of a secure deployed database or a complete security audit. No database access or permission changes were performed for this clarification.
Our apps never talk to Okta, Microsoft or Google directly. They speak OpenID Connect to one identity provider of ours, which acts as a broker: it holds local accounts and passkeys, and it federates out to each enterprise customer's own provider. The app sees a single issuer and a single token format whoever the customer is.
omniauth_openid_connect handles the app side.west-region-managers) map to our grants through a
per-organization table the customer's admin controls. Their directory decides who is in a group; our app
decides what that group may do.Because the app only knows OIDC_ISSUER, OIDC_CLIENT_ID and
OIDC_CLIENT_SECRET from its .env, replacing the broker is a configuration change.
That is the adapter rule from Own your stack, with the standard itself as
the interface.
All of these speak OIDC and SAML and support passkeys. The difference is weight, licence and how naturally they model many organizations.
| Option | Runs as | Good at | Watch for |
|---|---|---|---|
| Zitadel | One Go binary + PostgreSQL | Multitenancy is built in: organizations, per-org SSO, branding, and delegated admins | AGPL licence; younger ecosystem |
| Keycloak | Java (Quarkus) + PostgreSQL | The most mature; Apache licence; huge feature set; Organizations support in recent releases | Heaviest on memory; the admin console takes learning |
| Authentik | Python/Go + PostgreSQL | Friendly UI, flexible login flows, also fronts apps with no SSO of their own | Open core: some enterprise features are paid |
| Kanidm, Authelia | Small Rust / Go services | Very light; great for our own staff and home-lab tools | Not built for many customer organizations |
| WorkOS, Auth0, Clerk | Hosted | Fastest path to enterprise SSO and SCIM; nothing to run | Per-connection or per-user pricing; our users live in their database |
Our proposal: trial Zitadel first, because its data model already matches ours (organizations with their own SSO and admins) and it is light enough for this machine. Keycloak is the fallback if we hit a gap. A hosted broker stays an option for a customer who needs SSO before we can run one, and since apps only speak OIDC, moving between them costs a config change plus a user export. Nothing is installed yet.
Passwords get phished, reused and leaked. Passkeys (WebAuthn / FIDO2) don't: the browser signs a challenge with a key that is bound to our domain, so a look-alike site gets nothing useful.
webauthn gem is the Ruby implementation, on top of the authentication generator Rails ships.These rules are section 15 of AGENTS.md, which every coding agent working here follows.
Provided as is, for information only, with no warranty and no liability. Not legal or professional advice. Security and radio material is for authorized, lawful use only. Full disclaimer.
Visits are logged on our own server: IP address, browser and device details, and pages viewed. We use them to understand traffic and spot attacks, and never share or sell them.