pgmorbacMulti-OrBAC for PostgreSQL
    DocumentationDownloadSource
    Use cases
    • How do I...?
    Getting Started
    • Introduction
    • Quick start
    • How pgmorbac compares
    • Upgrade
    Concepts
    • Organizations
    • Roles
    • Activities
    • Views
    • Contexts
    • Rules & Modalities
    • Global Rules
    • System Principals
    • Constraints
    • Row-Level Security
    • Decision Cache
    Resolution
    • is_allowed
    Recipes
    • Org Hierarchies
    • Multi-Tenant Isolation
    • Wildcard Admin
    • Delegation
    • Service Accounts
    • Deny Rules
    • Separation of Duty
    • Derived Roles
    • Cross-Org Access
    • Obligations & Recommendations
    • Temporal Access
    Integration
    • Fastify
    • PostgREST & RLS Context
    • SQL Only
    Reference
    • Function Reference
    Recipes

    Multi-Tenant Isolation

    Guaranteeing sibling organizations cannot see each other.

    Situation. Clinique Valmont Nord and Clinique Valmont Sud are sibling hospitals in one database. A South nurse must never see North data, and you want that guaranteed by the database, not by application discipline.

    Assumes the shared Valmont setup.

    Isolation is the default

    pgmorbac grants nothing implicitly. Julien Lefevre is a nurse at Clinique Valmont Nord:

    INSERT INTO morbac.user_roles (user_id, role_id, org_id)
    SELECT '22222222-2222-4222-8222-222222222222', r.id, r.org_id
    FROM morbac.roles r JOIN morbac.orgs o ON o.id = r.org_id
    WHERE o.name = 'Clinique Valmont Nord' AND r.name = 'nurse'
    ON CONFLICT DO NOTHING;
     
    INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
    SELECT o.id, r.id, 'read', 'patients',
           (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'
    FROM morbac.orgs o
    JOIN morbac.roles r ON r.org_id = o.id AND r.name = 'nurse'
    WHERE o.name = 'Clinique Valmont Nord'
    ON CONFLICT DO NOTHING;
    SELECT morbac.is_allowed('22222222-2222-4222-8222-222222222222',
        (SELECT id FROM morbac.orgs WHERE name = 'Clinique Valmont Nord'),
        'read', 'patients');                                   -- true
    SELECT morbac.is_allowed('22222222-2222-4222-8222-222222222222',
        (SELECT id FROM morbac.orgs WHERE name = 'Clinique Valmont Sud'),
        'read', 'patients');                                   -- false

    South denies because nothing grants it: Julien holds no role there, no subtree rule from a common ancestor covers South, and the engine fails closed. Sibling isolation is not a feature you switch on; it is the absence of a grant.

    Making the database enforce it per row

    Authorization checks are only half the story; the other half is row-level security on your tables:

    CREATE TABLE IF NOT EXISTS app.patients (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        org_id UUID NOT NULL REFERENCES morbac.orgs(id),
        name TEXT NOT NULL
    );
    ALTER TABLE app.patients ENABLE ROW LEVEL SECURITY;
    DROP POLICY IF EXISTS patients_select ON app.patients;
    CREATE POLICY patients_select ON app.patients FOR SELECT
    USING (morbac.rls_check('read', 'patients', org_id));
     
    INSERT INTO app.patients (org_id, name)
    SELECT id, 'North patient' FROM morbac.orgs WHERE name = 'Clinique Valmont Nord';
    INSERT INTO app.patients (org_id, name)
    SELECT id, 'South patient' FROM morbac.orgs WHERE name = 'Clinique Valmont Sud';

    Proof under a non-privileged role

    The application role needs USAGE on morbac and EXECUTE on rls_check: policy expressions run with the privileges of the querying role, not the table owner. Grant those two and nothing else; the internals stay unreadable because every morbac function is SECURITY DEFINER.

    CREATE ROLE valmont_app LOGIN;               -- no BYPASSRLS
    GRANT USAGE ON SCHEMA app TO valmont_app;
    GRANT SELECT ON app.patients TO valmont_app;
    GRANT USAGE ON SCHEMA morbac TO valmont_app;
    GRANT EXECUTE ON FUNCTION morbac.rls_check(TEXT, TEXT, UUID, UUID) TO valmont_app;

    Resolve the org id first, then drop privileges: valmont_app cannot read morbac.orgs directly, and should not be able to.

    SELECT id AS nord_id FROM morbac.orgs WHERE name = 'Clinique Valmont Nord' \gset
     
    SET ROLE valmont_app;
    SELECT set_config('morbac.user_id', '22222222-2222-4222-8222-222222222222', true);
    SELECT set_config('morbac.org_id', :'nord_id', true);
     
    SELECT name FROM app.patients;                -- North patient (1 row)
    RESET ROLE;

    In an application, the org id comes from the request context, so the \gset step is just psql plumbing for this demo.

    Even if a buggy client asks for everything, the South row does not exist for Julien. Setting morbac.org_id to South instead yields zero rows, because rls_check then calls is_allowed for South and gets false.

    When you do want to cross the wall

    Deliberate exceptions are their own recipes: cross-org access for an HQ auditor, or a subtree rule from the common parent when the whole group should share a view.

    PreviousOrg HierarchiesNext Wildcard Admin
    pgmorbac - Multi-OrBAC permission engine for PostgreSQL
    DocumentationDownloadGitea