-- =============================================================================
-- morbac_pg Extension - Version 1.0
-- =============================================================================
-- Multi-OrBAC: Organization-Based Access Control with Multi-Organization Support
-- Based on the CNRS research paper on Multi-OrBAC model
--
-- This extension implements:
-- - Organization-Based Access Control (OrBAC)
-- - Multi-organization support
-- - Deontic modalities: permissions, prohibitions, obligations, recommendations
-- - Context-based rule evaluation
-- - Prohibition precedence over permissions
-- - Policy DSL for simplified rule declaration
-- - RLS helper functions for PostgREST compatibility
-- =============================================================================

-- Require pgcrypto for UUID generation
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Create the morbac schema
CREATE SCHEMA IF NOT EXISTS morbac;

COMMENT ON SCHEMA morbac IS 'Multi-OrBAC access control framework - all objects live in this schema';

-- =============================================================================
-- 1. DEONTIC MODALITY TYPE
-- =============================================================================
-- Represents the four deontic modalities of OrBAC model

CREATE TYPE morbac.modality AS ENUM (
    'permission',
    'prohibition',
    'obligation',
    'recommendation'
);

COMMENT ON TYPE morbac.modality IS 'Deontic modalities: permission, prohibition, obligation, recommendation';

-- =============================================================================
-- 2. ORGANIZATIONS
-- =============================================================================
-- Organizations are first-class entities in Multi-OrBAC
-- Each organization has its own policy space

CREATE TABLE morbac.orgs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL UNIQUE,
    parent_id UUID REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata JSONB DEFAULT '{}'::jsonb
);

CREATE INDEX idx_orgs_name ON morbac.orgs(name);
CREATE INDEX idx_orgs_parent ON morbac.orgs(parent_id);

COMMENT ON TABLE morbac.orgs IS 'Organizations - first-class entities in Multi-OrBAC with hierarchy support';
COMMENT ON COLUMN morbac.orgs.id IS 'Unique organization identifier';
COMMENT ON COLUMN morbac.orgs.name IS 'Organization name (unique)';
COMMENT ON COLUMN morbac.orgs.parent_id IS 'Parent organization for hierarchical organizations';
COMMENT ON COLUMN morbac.orgs.metadata IS 'Optional metadata for organization';

-- =============================================================================
-- 3. ROLES
-- =============================================================================
-- Roles are scoped to organizations
-- A role abstracts a set of subjects within an organization

CREATE TABLE morbac.roles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(org_id, name)
);

CREATE INDEX idx_roles_org_id ON morbac.roles(org_id);
CREATE INDEX idx_roles_org_name ON morbac.roles(org_id, name);

COMMENT ON TABLE morbac.roles IS 'Roles scoped to organizations - abstract sets of subjects';
COMMENT ON COLUMN morbac.roles.org_id IS 'Organization this role belongs to';
COMMENT ON COLUMN morbac.roles.name IS 'Role name (unique within organization)';

-- =============================================================================
-- 4. ROLE HIERARCHY
-- =============================================================================
-- Roles can inherit from other roles (role hierarchy)
-- Senior roles inherit permissions from junior roles

CREATE TABLE morbac.role_hierarchy (
    senior_role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    junior_role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (senior_role_id, junior_role_id),
    CHECK (senior_role_id != junior_role_id)
);

CREATE INDEX idx_role_hierarchy_senior ON morbac.role_hierarchy(senior_role_id);
CREATE INDEX idx_role_hierarchy_junior ON morbac.role_hierarchy(junior_role_id);

COMMENT ON TABLE morbac.role_hierarchy IS 'Role hierarchy - senior roles inherit from junior roles';
COMMENT ON COLUMN morbac.role_hierarchy.senior_role_id IS 'Senior role (inherits permissions)';
COMMENT ON COLUMN morbac.role_hierarchy.junior_role_id IS 'Junior role (provides permissions)';

-- =============================================================================
-- 5. USER-ROLE ASSIGNMENTS
-- =============================================================================
-- Maps users to roles within organizations
-- user_id is external (e.g., from authentication system)

CREATE TABLE morbac.user_roles (
    user_id UUID NOT NULL,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, role_id, org_id)
);

CREATE INDEX idx_user_roles_user_org ON morbac.user_roles(user_id, org_id);
CREATE INDEX idx_user_roles_role ON morbac.user_roles(role_id);

COMMENT ON TABLE morbac.user_roles IS 'Maps users to roles within organizations';
COMMENT ON COLUMN morbac.user_roles.user_id IS 'External user identifier';
COMMENT ON COLUMN morbac.user_roles.role_id IS 'Role assigned to the user';
COMMENT ON COLUMN morbac.user_roles.org_id IS 'Organization context for this assignment';

-- =============================================================================
-- 5a. DELEGATION
-- =============================================================================
-- Users can delegate their permissions to other users temporarily
-- Delegation is time-bounded and role-scoped

CREATE TABLE morbac.delegations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    delegator_id UUID NOT NULL,
    delegatee_id UUID NOT NULL,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
    valid_until TIMESTAMPTZ NOT NULL,
    revoked BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (delegator_id != delegatee_id),
    CHECK (valid_until > valid_from)
);

CREATE INDEX idx_delegations_delegatee ON morbac.delegations(delegatee_id, org_id);
CREATE INDEX idx_delegations_validity ON morbac.delegations(valid_from, valid_until) WHERE NOT revoked;

COMMENT ON TABLE morbac.delegations IS 'Temporary delegation of roles from one user to another';
COMMENT ON COLUMN morbac.delegations.delegator_id IS 'User delegating the role';
COMMENT ON COLUMN morbac.delegations.delegatee_id IS 'User receiving the delegated role';
COMMENT ON COLUMN morbac.delegations.role_id IS 'Role being delegated';
COMMENT ON COLUMN morbac.delegations.valid_from IS 'Delegation start time';
COMMENT ON COLUMN morbac.delegations.valid_until IS 'Delegation end time';
COMMENT ON COLUMN morbac.delegations.revoked IS 'Whether delegation has been revoked';

-- =============================================================================
-- 5b. NEGATIVE ROLE ASSIGNMENTS
-- =============================================================================
-- Explicitly prevent users from ever getting certain roles
-- Takes precedence over positive assignments

CREATE TABLE morbac.negative_role_assignments (
    user_id UUID NOT NULL,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    reason TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, role_id, org_id)
);

CREATE INDEX idx_negative_assignments ON morbac.negative_role_assignments(user_id, org_id);

COMMENT ON TABLE morbac.negative_role_assignments IS 'Explicit prohibition of role assignments';
COMMENT ON COLUMN morbac.negative_role_assignments.user_id IS 'User prohibited from having role';
COMMENT ON COLUMN morbac.negative_role_assignments.role_id IS 'Role that is prohibited';
COMMENT ON COLUMN morbac.negative_role_assignments.reason IS 'Reason for prohibition';

-- =============================================================================
-- 5c. SEPARATION OF DUTY (SoD)
-- =============================================================================
-- Define mutually exclusive roles that cannot be held simultaneously

CREATE TABLE morbac.sod_conflicts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    role_a_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    role_b_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (role_a_id != role_b_id),
    UNIQUE (role_a_id, role_b_id, org_id)
);

CREATE INDEX idx_sod_conflicts_org ON morbac.sod_conflicts(org_id);
CREATE INDEX idx_sod_conflicts_roles ON morbac.sod_conflicts(role_a_id, role_b_id);

COMMENT ON TABLE morbac.sod_conflicts IS 'Separation of Duty: mutually exclusive roles';
COMMENT ON COLUMN morbac.sod_conflicts.role_a_id IS 'First conflicting role';
COMMENT ON COLUMN morbac.sod_conflicts.role_b_id IS 'Second conflicting role';
COMMENT ON COLUMN morbac.sod_conflicts.description IS 'Description of the conflict';

-- =============================================================================
-- 5d. CARDINALITY CONSTRAINTS
-- =============================================================================
-- Limit the number of users that can have a specific role

CREATE TABLE morbac.role_cardinality (
    role_id UUID PRIMARY KEY REFERENCES morbac.roles(id) ON DELETE CASCADE,
    min_users INTEGER,
    max_users INTEGER,
    description TEXT,
    CHECK (min_users IS NULL OR min_users >= 0),
    CHECK (max_users IS NULL OR max_users >= 1),
    CHECK (min_users IS NULL OR max_users IS NULL OR max_users >= min_users)
);

COMMENT ON TABLE morbac.role_cardinality IS 'Cardinality constraints for roles (min/max number of users)';
COMMENT ON COLUMN morbac.role_cardinality.min_users IS 'Minimum number of users required for this role';
COMMENT ON COLUMN morbac.role_cardinality.max_users IS 'Maximum number of users allowed for this role';

-- =============================================================================
-- 5e. DERIVED ROLES
-- =============================================================================
-- Roles computed dynamically based on conditions rather than explicit assignment

CREATE TABLE morbac.derived_roles (
    role_id UUID PRIMARY KEY REFERENCES morbac.roles(id) ON DELETE CASCADE,
    condition_evaluator REGPROC NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE morbac.derived_roles IS 'Roles computed dynamically based on conditions';
COMMENT ON COLUMN morbac.derived_roles.role_id IS 'Role that is derived';
COMMENT ON COLUMN morbac.derived_roles.condition_evaluator IS 'Function(user_id, org_id) returning boolean';

-- =============================================================================
-- 6. ACTIVITIES
-- =============================================================================
-- Activities represent abstract actions in OrBAC
-- These are global abstractions (not org-scoped)

CREATE TABLE morbac.activities (
    name TEXT PRIMARY KEY,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE morbac.activities IS 'Activities - abstract actions in OrBAC model (global)';
COMMENT ON COLUMN morbac.activities.name IS 'Activity name (unique, global)';

-- =============================================================================
-- 6a. ACTIVITY HIERARCHY
-- =============================================================================
-- Activities can inherit from other activities
-- e.g., "write" implies "read", "admin_delete" implies "delete"

CREATE TABLE morbac.activity_hierarchy (
    senior_activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
    junior_activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (senior_activity, junior_activity),
    CHECK (senior_activity != junior_activity)
);

CREATE INDEX idx_activity_hierarchy_senior ON morbac.activity_hierarchy(senior_activity);
CREATE INDEX idx_activity_hierarchy_junior ON morbac.activity_hierarchy(junior_activity);

COMMENT ON TABLE morbac.activity_hierarchy IS 'Activity hierarchy - senior activities imply junior activities';
COMMENT ON COLUMN morbac.activity_hierarchy.senior_activity IS 'Senior activity (implies junior)';
COMMENT ON COLUMN morbac.activity_hierarchy.junior_activity IS 'Junior activity (implied by senior)';

-- =============================================================================
-- 7. VIEWS
-- =============================================================================
-- Views represent abstract object categories in OrBAC
-- These are global abstractions (not org-scoped)

CREATE TABLE morbac.views (
    name TEXT PRIMARY KEY,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

COMMENT ON TABLE morbac.views IS 'Views - abstract object categories in OrBAC model (global)';
COMMENT ON COLUMN morbac.views.name IS 'View name (unique, global)';

-- =============================================================================
-- 7a. VIEW HIERARCHY
-- =============================================================================
-- Views can inherit from other views
-- e.g., "confidential_documents" is a "documents", "admin_reports" is a "reports"

CREATE TABLE morbac.view_hierarchy (
    senior_view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
    junior_view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (senior_view, junior_view),
    CHECK (senior_view != junior_view)
);

CREATE INDEX idx_view_hierarchy_senior ON morbac.view_hierarchy(senior_view);
CREATE INDEX idx_view_hierarchy_junior ON morbac.view_hierarchy(junior_view);

COMMENT ON TABLE morbac.view_hierarchy IS 'View hierarchy - senior views inherit from junior views';
COMMENT ON COLUMN morbac.view_hierarchy.senior_view IS 'Senior view (more specific)';
COMMENT ON COLUMN morbac.view_hierarchy.junior_view IS 'Junior view (more general)';

-- =============================================================================
-- 8. CONTEXTS
-- =============================================================================
-- Contexts represent conditions under which rules apply
-- Implemented as callable predicates (functions)

CREATE TABLE morbac.contexts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL UNIQUE,
    description TEXT,
    evaluator REGPROC NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_contexts_name ON morbac.contexts(name);

COMMENT ON TABLE morbac.contexts IS 'Contexts - conditions under which rules apply (callable predicates)';
COMMENT ON COLUMN morbac.contexts.name IS 'Context name (unique)';
COMMENT ON COLUMN morbac.contexts.evaluator IS 'Function that evaluates this context (returns boolean)';

-- =============================================================================
-- 9. DEFAULT CONTEXT: ALWAYS
-- =============================================================================
-- Create a default context that always evaluates to true

CREATE OR REPLACE FUNCTION morbac.context_always()
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN TRUE;
END;
$$;

COMMENT ON FUNCTION morbac.context_always() IS 'Default context evaluator - always returns true';

-- Insert the default 'always' context
INSERT INTO morbac.contexts (name, description, evaluator)
VALUES (
    'always',
    'Default context - always evaluates to true',
    'morbac.context_always'::regproc
);

-- =============================================================================
-- 10. RULES (Core OrBAC Policy)
-- =============================================================================
-- Implements the OrBAC rule relation:
-- Rule(org, role, activity, view, context, modality)
--
-- Represents: Permission, Prohibition, Obligation, or Recommendation

CREATE TABLE morbac.rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
    view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
    context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
    modality morbac.modality NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata JSONB DEFAULT '{}'::jsonb,
    UNIQUE(org_id, role_id, activity, view, context_id, modality)
);

CREATE INDEX idx_rules_org_role ON morbac.rules(org_id, role_id);
CREATE INDEX idx_rules_activity_view ON morbac.rules(activity, view);
CREATE INDEX idx_rules_modality ON morbac.rules(modality);
CREATE INDEX idx_rules_lookup ON morbac.rules(org_id, role_id, activity, view, modality);

COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
COMMENT ON COLUMN morbac.rules.org_id IS 'Organization scope';
COMMENT ON COLUMN morbac.rules.role_id IS 'Role this rule applies to';
COMMENT ON COLUMN morbac.rules.activity IS 'Activity (abstract action)';
COMMENT ON COLUMN morbac.rules.view IS 'View (abstract object category)';
COMMENT ON COLUMN morbac.rules.context_id IS 'Context condition';
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';

-- =============================================================================
-- 10a. INTER-ORGANIZATIONAL RULES
-- =============================================================================
-- Rules that apply across organizations (cross-org access)
-- Allows users from one org to access resources in another org

CREATE TABLE morbac.cross_org_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    target_org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    activity TEXT NOT NULL REFERENCES morbac.activities(name) ON DELETE CASCADE,
    view TEXT NOT NULL REFERENCES morbac.views(name) ON DELETE CASCADE,
    context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
    modality morbac.modality NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata JSONB DEFAULT '{}'::jsonb,
    CHECK (source_org_id != target_org_id),
    UNIQUE(source_org_id, target_org_id, role_id, activity, view, context_id, modality)
);

CREATE INDEX idx_cross_org_rules_source ON morbac.cross_org_rules(source_org_id, role_id);
CREATE INDEX idx_cross_org_rules_target ON morbac.cross_org_rules(target_org_id);

COMMENT ON TABLE morbac.cross_org_rules IS 'Inter-organizational rules for cross-org access';
COMMENT ON COLUMN morbac.cross_org_rules.source_org_id IS 'Organization where user has role';
COMMENT ON COLUMN morbac.cross_org_rules.target_org_id IS 'Organization where resource resides';

-- =============================================================================
-- 10b. ADMINISTRATION RULES
-- =============================================================================
-- Meta-policies defining who can create/modify policies (AdministrationPermission)

CREATE TABLE morbac.admin_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
    role_id UUID NOT NULL REFERENCES morbac.roles(id) ON DELETE CASCADE,
    admin_activity TEXT NOT NULL,
    admin_target TEXT NOT NULL,
    context_id UUID NOT NULL REFERENCES morbac.contexts(id) ON DELETE CASCADE,
    modality morbac.modality NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata JSONB DEFAULT '{}'::jsonb,
    UNIQUE(org_id, role_id, admin_activity, admin_target, context_id, modality)
);

CREATE INDEX idx_admin_rules_org_role ON morbac.admin_rules(org_id, role_id);

COMMENT ON TABLE morbac.admin_rules IS 'Administration rules - meta-policies for policy management';
COMMENT ON COLUMN morbac.admin_rules.admin_activity IS 'Admin action: create_rule, modify_rule, delete_rule, assign_role, etc.';
COMMENT ON COLUMN morbac.admin_rules.admin_target IS 'What can be administered: rules, roles, users, orgs, etc.';

-- =============================================================================
-- 11. HIERARCHY FUNCTIONS
-- =============================================================================
-- Functions to compute transitive closures for organization and role hierarchies

-- Get all ancestor organizations (including self)
CREATE OR REPLACE FUNCTION morbac.get_org_ancestors(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE org_ancestors AS (
        -- Base case: the organization itself
        SELECT p_org_id AS org_id, 0 AS depth
        UNION
        -- Recursive case: parent organizations
        SELECT o.parent_id, oa.depth + 1
        FROM org_ancestors oa
        INNER JOIN morbac.orgs o ON o.id = oa.org_id
        WHERE o.parent_id IS NOT NULL
    )
    SELECT * FROM org_ancestors;
END;
$$;

COMMENT ON FUNCTION morbac.get_org_ancestors(UUID) IS
'Returns all ancestor organizations (including self) with depth in hierarchy';

-- Get all descendant organizations (including self)
CREATE OR REPLACE FUNCTION morbac.get_org_descendants(p_org_id UUID)
RETURNS TABLE(org_id UUID, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE org_descendants AS (
        -- Base case: the organization itself
        SELECT p_org_id AS org_id, 0 AS depth
        UNION
        -- Recursive case: child organizations
        SELECT o.id, od.depth + 1
        FROM org_descendants od
        INNER JOIN morbac.orgs o ON o.parent_id = od.org_id
    )
    SELECT * FROM org_descendants;
END;
$$;

COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS
'Returns all descendant organizations (including self) with depth in hierarchy';

-- Get all roles a user effectively has (direct + inherited via role hierarchy)
CREATE OR REPLACE FUNCTION morbac.get_effective_roles(p_user_id UUID, p_org_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE effective_roles AS (
        -- Base case: directly assigned roles
        SELECT ur.role_id, 0 AS depth
        FROM morbac.user_roles ur
        WHERE ur.user_id = p_user_id
          AND ur.org_id = p_org_id
        UNION
        -- Recursive case: senior roles (roles that inherit from assigned roles)
        SELECT rh.senior_role_id, er.depth + 1
        FROM effective_roles er
        INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id
    )
    SELECT DISTINCT ON (role_id) role_id, depth
    FROM effective_roles
    ORDER BY role_id, depth;
END;
$$;

COMMENT ON FUNCTION morbac.get_effective_roles(UUID, UUID) IS
'Returns all effective roles for a user in an organization (direct + inherited via role hierarchy)';

-- Get all roles inherited by a role (transitive closure)
CREATE OR REPLACE FUNCTION morbac.get_inherited_roles(p_role_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE inherited_roles AS (
        -- Base case: the role itself
        SELECT p_role_id AS role_id, 0 AS depth
        UNION
        -- Recursive case: junior roles
        SELECT rh.junior_role_id, ir.depth + 1
        FROM inherited_roles ir
        INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id
    )
    SELECT DISTINCT ON (role_id) role_id, depth
    FROM inherited_roles
    ORDER BY role_id, depth;
END;
$$;

COMMENT ON FUNCTION morbac.get_inherited_roles(UUID) IS
'Returns all roles inherited by a role (transitive closure via role hierarchy)';

-- Get all effective activities (including inherited via activity hierarchy)
CREATE OR REPLACE FUNCTION morbac.get_effective_activities(p_activity TEXT)
RETURNS TABLE(activity TEXT, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE effective_activities AS (
        -- Base case: the activity itself
        SELECT p_activity AS activity, 0 AS depth
        UNION
        -- Recursive case: junior activities (implied activities)
        SELECT ah.junior_activity, ea.depth + 1
        FROM effective_activities ea
        INNER JOIN morbac.activity_hierarchy ah ON ah.senior_activity = ea.activity
    )
    SELECT DISTINCT ON (activity) activity, depth
    FROM effective_activities
    ORDER BY activity, depth;
END;
$$;

COMMENT ON FUNCTION morbac.get_effective_activities(TEXT) IS
'Returns all activities including those implied via activity hierarchy (e.g., write implies read)';

-- Get all effective views (including inherited via view hierarchy)
CREATE OR REPLACE FUNCTION morbac.get_effective_views(p_view TEXT)
RETURNS TABLE(view TEXT, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE effective_views AS (
        -- Base case: the view itself
        SELECT p_view AS view, 0 AS depth
        UNION
        -- Recursive case: junior views (more general categories)
        SELECT vh.junior_view, ev.depth + 1
        FROM effective_views ev
        INNER JOIN morbac.view_hierarchy vh ON vh.senior_view = ev.view
    )
    SELECT DISTINCT ON (view) view, depth
    FROM effective_views
    ORDER BY view, depth;
END;
$$;

COMMENT ON FUNCTION morbac.get_effective_views(TEXT) IS
'Returns all views including parent categories via view hierarchy';

-- Get comprehensive effective roles including delegation and derived roles
CREATE OR REPLACE FUNCTION morbac.get_comprehensive_roles(p_user_id UUID, p_org_id UUID)
RETURNS TABLE(role_id UUID, source TEXT, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE effective_roles AS (
        -- Direct role assignments
        SELECT ur.role_id, 'direct'::TEXT as source, 0 AS depth
        FROM morbac.user_roles ur
        WHERE ur.user_id = p_user_id
          AND ur.org_id = p_org_id
          -- Check not negatively assigned
          AND NOT EXISTS (
              SELECT 1 FROM morbac.negative_role_assignments nra
              WHERE nra.user_id = p_user_id
                AND nra.role_id = ur.role_id
                AND nra.org_id = p_org_id
          )

        UNION

        -- Delegated roles (active and not revoked)
        SELECT d.role_id, 'delegation'::TEXT, 0 AS depth
        FROM morbac.delegations d
        WHERE d.delegatee_id = p_user_id
          AND d.org_id = p_org_id
          AND NOT d.revoked
          AND now() BETWEEN d.valid_from AND d.valid_until
          -- Check delegator has the role
          AND EXISTS (
              SELECT 1 FROM morbac.user_roles ur
              WHERE ur.user_id = d.delegator_id
                AND ur.role_id = d.role_id
                AND ur.org_id = d.org_id
          )
          -- Check not negatively assigned to delegatee
          AND NOT EXISTS (
              SELECT 1 FROM morbac.negative_role_assignments nra
              WHERE nra.user_id = p_user_id
                AND nra.role_id = d.role_id
                AND nra.org_id = p_org_id
          )

        UNION

        -- Derived roles (computed dynamically)
        -- Note: derived roles are evaluated separately due to EXECUTE limitations
        -- Use morbac.check_derived_role() helper

        UNION

        -- Role hierarchy (senior roles)
        SELECT rh.senior_role_id, er.source || '_inherited', er.depth + 1
        FROM effective_roles er
        INNER JOIN morbac.role_hierarchy rh ON rh.junior_role_id = er.role_id
    )
    SELECT DISTINCT ON (role_id) role_id, source, depth
    FROM effective_roles
    WHERE role_id IS NOT NULL
    ORDER BY role_id, depth;

    -- Add derived roles separately
    RETURN QUERY
    SELECT dr.role_id, 'derived'::TEXT, 0
    FROM morbac.derived_roles dr
    INNER JOIN morbac.roles r ON r.id = dr.role_id
    WHERE r.org_id = p_org_id
      AND morbac.eval_derived_role(dr.condition_evaluator, p_user_id, p_org_id)
      AND NOT EXISTS (
          SELECT 1 FROM morbac.negative_role_assignments nra
          WHERE nra.user_id = p_user_id
            AND nra.role_id = dr.role_id
            AND nra.org_id = p_org_id
      );
END;
$$;

COMMENT ON FUNCTION morbac.get_comprehensive_roles(UUID, UUID) IS
'Returns all effective roles including direct, delegated, derived, and inherited via hierarchy';

-- Helper to evaluate derived role conditions
CREATE OR REPLACE FUNCTION morbac.eval_derived_role(
    p_evaluator REGPROC,
    p_user_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_result BOOLEAN;
BEGIN
    EXECUTE format('SELECT %s(%L, %L)', p_evaluator::text, p_user_id, p_org_id) INTO v_result;
    RETURN COALESCE(v_result, FALSE);
EXCEPTION
    WHEN OTHERS THEN
        RETURN FALSE;
END;
$$;

COMMENT ON FUNCTION morbac.eval_derived_role(REGPROC, UUID, UUID) IS
'Evaluates a derived role condition function';

-- Get all roles inherited by a role (transitive closure)
CREATE OR REPLACE FUNCTION morbac.get_inherited_roles(p_role_id UUID)
RETURNS TABLE(role_id UUID, depth INTEGER)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE inherited_roles AS (
        -- Base case: the role itself
        SELECT p_role_id AS role_id, 0 AS depth
        UNION
        -- Recursive case: junior roles
        SELECT rh.junior_role_id, ir.depth + 1
        FROM inherited_roles ir
        INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = ir.role_id
    )
    SELECT DISTINCT ON (role_id) role_id, depth
    FROM inherited_roles
    ORDER BY role_id, depth;
END;
$$;

COMMENT ON FUNCTION morbac.get_inherited_roles(UUID) IS
'Returns all roles inherited by a role (transitive closure via role hierarchy)';

-- =============================================================================
-- 11a. VALIDATION FUNCTIONS
-- =============================================================================

-- Check if role assignment would violate Separation of Duty
CREATE OR REPLACE FUNCTION morbac.check_sod_violation(
    p_user_id UUID,
    p_role_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_conflict_exists BOOLEAN;
BEGIN
    -- Check if user already has a conflicting role
    SELECT EXISTS (
        SELECT 1
        FROM morbac.user_roles ur
        INNER JOIN morbac.sod_conflicts sod ON (
            (sod.role_a_id = ur.role_id AND sod.role_b_id = p_role_id)
            OR (sod.role_b_id = ur.role_id AND sod.role_a_id = p_role_id)
        )
        WHERE ur.user_id = p_user_id
          AND ur.org_id = p_org_id
          AND sod.org_id = p_org_id
    ) INTO v_conflict_exists;

    RETURN v_conflict_exists;
END;
$$;

COMMENT ON FUNCTION morbac.check_sod_violation(UUID, UUID, UUID) IS
'Returns true if assigning role would violate Separation of Duty constraints';

-- Check if role assignment would violate cardinality constraints
CREATE OR REPLACE FUNCTION morbac.check_cardinality_violation(
    p_role_id UUID,
    p_adding BOOLEAN DEFAULT TRUE
)
RETURNS TEXT
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_current_count INTEGER;
    v_min_users INTEGER;
    v_max_users INTEGER;
BEGIN
    -- Get current user count and constraints
    SELECT
        COUNT(DISTINCT ur.user_id),
        rc.min_users,
        rc.max_users
    INTO v_current_count, v_min_users, v_max_users
    FROM morbac.user_roles ur
    LEFT JOIN morbac.role_cardinality rc ON rc.role_id = ur.role_id
    WHERE ur.role_id = p_role_id
    GROUP BY rc.min_users, rc.max_users;

    -- Check max constraint when adding
    IF p_adding AND v_max_users IS NOT NULL THEN
        IF v_current_count >= v_max_users THEN
            RETURN format('Maximum users (%s) reached for role', v_max_users);
        END IF;
    END IF;

    -- Check min constraint when removing
    IF NOT p_adding AND v_min_users IS NOT NULL THEN
        IF v_current_count <= v_min_users THEN
            RETURN format('Minimum users (%s) required for role', v_min_users);
        END IF;
    END IF;

    RETURN NULL; -- No violation
END;
$$;

COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS
'Returns error message if cardinality constraint would be violated, NULL otherwise';

-- =============================================================================
-- 12. CONTEXT EVALUATION HELPER
-- =============================================================================
-- Evaluates a context by calling its evaluator function

CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_evaluator REGPROC;
    v_result BOOLEAN;
BEGIN
    -- Get the evaluator function for this context
    SELECT evaluator INTO v_evaluator
    FROM morbac.contexts
    WHERE id = p_context_id;

    IF v_evaluator IS NULL THEN
        RAISE EXCEPTION 'Context % not found', p_context_id;
    END IF;

    -- Execute the evaluator function
    EXECUTE format('SELECT %s()', v_evaluator::text) INTO v_result;

    RETURN COALESCE(v_result, FALSE);
END;
$$;

COMMENT ON FUNCTION morbac.eval_context(UUID) IS 'Evaluates a context by calling its evaluator function';

-- =============================================================================
-- 12. CONTEXT EVALUATION DECISION FUNCTION (CRITICAL)
-- =============================================================================
-- Implements the canonical OrBAC authorization decision
--
-- Semantics (from Multi-OrBAC paper):
-- - Access is allowed if and only if:
--   1. At least one applicable permission exists
--   2. AND no applicable prohibition exists
-- - Prohibitions have precedence over permissions
-- - Contexts must be evaluated
-- - Default deny (no permission = deny)
-- - Obligations and recommendations do NOT affect authorization

CREATE OR REPLACE FUNCTION morbac.is_allowed(
    p_user_id UUID,
    p_org_id UUID,
    p_activity TEXT,
    p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_rule RECORD;
BEGIN
    -- STEP 1: Check for prohibitions first (prohibition precedence)
    -- If any applicable prohibition exists, deny immediately
    -- Checks:
    -- - Direct rules for (activity, view) combinations
    -- - Activity hierarchy (senior activities imply junior)
    -- - View hierarchy (senior views imply junior)
    -- - Comprehensive roles (direct, delegated, derived, inherited)

    FOR v_rule IN
        SELECT r.context_id
        FROM morbac.rules r
        WHERE r.org_id = p_org_id
          AND r.modality = 'prohibition'
          -- Match activity or any senior activity in hierarchy
          AND r.activity IN (
              SELECT activity FROM morbac.get_effective_activities(p_activity)
          )
          -- Match view or any senior view in hierarchy
          AND r.view IN (
              SELECT view FROM morbac.get_effective_views(p_view)
          )
          -- Match comprehensive roles (direct, delegated, derived, inherited)
          AND r.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
          )
    LOOP
        -- Evaluate context
        IF morbac.eval_context(v_rule.context_id) THEN
            -- Prohibition found - immediate deny (prohibition precedence)
            RETURN FALSE;
        END IF;
    END LOOP;

    -- STEP 2: Check cross-organizational prohibitions
    FOR v_rule IN
        SELECT cr.context_id
        FROM morbac.cross_org_rules cr
        WHERE cr.target_org_id = p_org_id
          AND cr.modality = 'prohibition'
          AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
          AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
          -- User has role in source org
          AND cr.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)
          )
    LOOP
        IF morbac.eval_context(v_rule.context_id) THEN
            RETURN FALSE;
        END IF;
    END LOOP;

    -- STEP 3: No prohibitions found, check for permissions
    FOR v_rule IN
        SELECT r.context_id
        FROM morbac.rules r
        WHERE r.org_id = p_org_id
          AND r.modality = 'permission'
          -- Match activity or any senior activity in hierarchy
          AND r.activity IN (
              SELECT activity FROM morbac.get_effective_activities(p_activity)
          )
          -- Match view or any senior view in hierarchy
          AND r.view IN (
              SELECT view FROM morbac.get_effective_views(p_view)
          )
          -- Match comprehensive roles
          AND r.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
          )
    LOOP
        -- Evaluate context
        IF morbac.eval_context(v_rule.context_id) THEN
            -- Permission found and no prohibition - allow
            RETURN TRUE;
        END IF;
    END LOOP;

    -- STEP 4: Check cross-organizational permissions
    FOR v_rule IN
        SELECT cr.context_id
        FROM morbac.cross_org_rules cr
        WHERE cr.target_org_id = p_org_id
          AND cr.modality = 'permission'
          AND cr.activity IN (SELECT activity FROM morbac.get_effective_activities(p_activity))
          AND cr.view IN (SELECT view FROM morbac.get_effective_views(p_view))
          AND cr.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, cr.source_org_id)
          )
    LOOP
        IF morbac.eval_context(v_rule.context_id) THEN
            RETURN TRUE;
        END IF;
    END LOOP;

    -- No permission found - deny (default deny)
    RETURN FALSE;
END;
$$;

COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS
'Complete OrBAC authorization: role/activity/view hierarchies, delegation, derived roles, cross-org, prohibition precedence';

-- =============================================================================
-- 13. AUTHORIZATION TABLE
-- =============================================================================
-- Simplified table for developers to declare policy
-- Uses friendly names instead of UUIDs

CREATE TABLE morbac.policy (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_name TEXT NOT NULL,
    role_name TEXT NOT NULL,
    activity TEXT NOT NULL,
    view TEXT NOT NULL,
    modality morbac.modality NOT NULL,
    context_name TEXT NOT NULL DEFAULT 'always',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    compiled BOOLEAN NOT NULL DEFAULT FALSE,
    UNIQUE(org_name, role_name, activity, view, modality, context_name)
);

CREATE INDEX idx_policy_not_compiled ON morbac.policy(compiled) WHERE NOT compiled;

COMMENT ON TABLE morbac.policy IS 'Policy DSL - simplified policy declaration using names';
COMMENT ON COLUMN morbac.policy.org_name IS 'Organization name (resolved during compilation)';
COMMENT ON COLUMN morbac.policy.role_name IS 'Role name (resolved during compilation)';
COMMENT ON COLUMN morbac.policy.activity IS 'Activity name';
COMMENT ON COLUMN morbac.policy.view IS 'View name';
COMMENT ON COLUMN morbac.policy.modality IS 'Deontic modality';
COMMENT ON COLUMN morbac.policy.context_name IS 'Context name (default: always)';
COMMENT ON COLUMN morbac.policy.compiled IS 'Whether this policy entry has been compiled into rules';

COMMENT ON FUNCTION morbac.is_allowed(UUID, UUID, TEXT, TEXT) IS
'Complete OrBAC authorization: role/activity/view hierarchies, delegation, derived roles, cross-org, prohibition precedence';

-- Helper function to check administration permissions
CREATE OR REPLACE FUNCTION morbac.is_admin_allowed(
    p_user_id UUID,
    p_org_id UUID,
    p_admin_activity TEXT,
    p_admin_target TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_rule RECORD;
BEGIN
    -- Check for admin prohibitions first
    FOR v_rule IN
        SELECT ar.context_id
        FROM morbac.admin_rules ar
        WHERE ar.org_id = p_org_id
          AND ar.admin_activity = p_admin_activity
          AND ar.admin_target = p_admin_target
          AND ar.modality = 'prohibition'
          AND ar.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
          )
    LOOP
        IF morbac.eval_context(v_rule.context_id) THEN
            RETURN FALSE;
        END IF;
    END LOOP;

    -- Check for admin permissions
    FOR v_rule IN
        SELECT ar.context_id
        FROM morbac.admin_rules ar
        WHERE ar.org_id = p_org_id
          AND ar.admin_activity = p_admin_activity
          AND ar.admin_target = p_admin_target
          AND ar.modality = 'permission'
          AND ar.role_id IN (
              SELECT role_id FROM morbac.get_comprehensive_roles(p_user_id, p_org_id)
          )
    LOOP
        IF morbac.eval_context(v_rule.context_id) THEN
            RETURN TRUE;
        END IF;
    END LOOP;

    RETURN FALSE; -- Default deny
END;
$$;

COMMENT ON FUNCTION morbac.is_admin_allowed(UUID, UUID, TEXT, TEXT) IS
'Checks administration permissions for policy management operations';

-- =============================================================================
-- 14. POLICY DSL
-- =============================================================================
-- Translates policy DSL entries into concrete rules
-- Resolves names to IDs
-- Idempotent - safe to run multiple times

CREATE OR REPLACE FUNCTION morbac.compile_policy()
RETURNS TABLE(
    compiled_count INTEGER,
    error_count INTEGER,
    errors TEXT[]
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_policy RECORD;
    v_org_id UUID;
    v_role_id UUID;
    v_context_id UUID;
    v_compiled INTEGER := 0;
    v_errors TEXT[] := ARRAY[]::TEXT[];
    v_error_count INTEGER := 0;
BEGIN
    -- Process all uncompiled policy entries
    FOR v_policy IN
        SELECT * FROM morbac.policy WHERE NOT compiled
    LOOP
        BEGIN
            -- Resolve organization
            SELECT id INTO STRICT v_org_id
            FROM morbac.orgs
            WHERE name = v_policy.org_name;

            -- Resolve role within organization
            SELECT id INTO STRICT v_role_id
            FROM morbac.roles
            WHERE org_id = v_org_id AND name = v_policy.role_name;

            -- Resolve context
            SELECT id INTO STRICT v_context_id
            FROM morbac.contexts
            WHERE name = v_policy.context_name;

            -- Ensure activity exists
            INSERT INTO morbac.activities (name)
            VALUES (v_policy.activity)
            ON CONFLICT (name) DO NOTHING;

            -- Ensure view exists
            INSERT INTO morbac.views (name)
            VALUES (v_policy.view)
            ON CONFLICT (name) DO NOTHING;

            -- Insert rule (ignore if already exists)
            INSERT INTO morbac.rules (org_id, role_id, activity, view, context_id, modality)
            VALUES (v_org_id, v_role_id, v_policy.activity, v_policy.view, v_context_id, v_policy.modality)
            ON CONFLICT (org_id, role_id, activity, view, context_id, modality) DO NOTHING;

            -- Mark as compiled
            UPDATE morbac.policy SET compiled = TRUE WHERE id = v_policy.id;

            v_compiled := v_compiled + 1;

        EXCEPTION WHEN OTHERS THEN
            v_error_count := v_error_count + 1;
            v_errors := array_append(v_errors,
                format('Policy %s: %s', v_policy.id, SQLERRM));
        END;
    END LOOP;

    RETURN QUERY SELECT v_compiled, v_error_count, v_errors;
END;
$$;

COMMENT ON FUNCTION morbac.compile_policy() IS
'Compiles policy DSL entries into concrete rules - idempotent and safe to run multiple times';

-- =============================================================================
-- 15. POLICY COMPILER HELPER FUNCTIONS
-- =============================================================================
-- Helper functions for Row-Level Security policies
-- Compatible with PostgREST

-- Get current user ID from request header
CREATE OR REPLACE FUNCTION morbac.current_user_id()
RETURNS UUID
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_user_id TEXT;
BEGIN
    -- Read from PostgREST request.header.x-user-id setting
    v_user_id := current_setting('request.header.x-user-id', TRUE);

    IF v_user_id IS NULL OR v_user_id = '' THEN
        RETURN NULL;
    END IF;

    RETURN v_user_id::UUID;
EXCEPTION
    WHEN OTHERS THEN
        RETURN NULL;
END;
$$;

COMMENT ON FUNCTION morbac.current_user_id() IS
'Returns current user ID from request.header.x-user-id (PostgREST compatible)';

-- Get current organization ID from request header
CREATE OR REPLACE FUNCTION morbac.current_org_id()
RETURNS UUID
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_org_id TEXT;
BEGIN
    -- Read from PostgREST request.header.x-org-id setting
    v_org_id := current_setting('request.header.x-org-id', TRUE);

    IF v_org_id IS NULL OR v_org_id = '' THEN
        RETURN NULL;
    END IF;

    RETURN v_org_id::UUID;
EXCEPTION
    WHEN OTHERS THEN
        RETURN NULL;
END;
$$;

COMMENT ON FUNCTION morbac.current_org_id() IS
'Returns current organization ID from request.header.x-org-id (PostgREST compatible)';

-- RLS check function
CREATE OR REPLACE FUNCTION morbac.rls_check(
    p_activity TEXT,
    p_view TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_user_id UUID;
    v_org_id UUID;
BEGIN
    v_user_id := morbac.current_user_id();
    v_org_id := morbac.current_org_id();

    -- If no user or org context, deny
    IF v_user_id IS NULL OR v_org_id IS NULL THEN
        RETURN FALSE;
    END IF;

    -- Call authorization decision function
    RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view);
END;
$$;

COMMENT ON FUNCTION morbac.rls_check(TEXT, TEXT) IS
'RLS helper: checks if current user is allowed to perform activity on view in current org';

-- =============================================================================
-- 16. RLS AND RECOMMENDATIONS VIEWS
-- =============================================================================
-- Obligations and recommendations do NOT affect authorization
-- They are queryable for informational purposes

-- Pending obligations for a user in an organization
CREATE OR REPLACE FUNCTION morbac.pending_obligations(
    p_user_id UUID,
    p_org_id UUID
)
RETURNS TABLE(
    rule_id UUID,
    role_name TEXT,
    activity TEXT,
    view TEXT,
    context_name TEXT,
    created_at TIMESTAMPTZ
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    SELECT
        r.id,
        ro.name,
        r.activity,
        r.view,
        c.name,
        r.created_at
    FROM morbac.rules r
    INNER JOIN morbac.user_roles ur ON ur.role_id = r.role_id
    INNER JOIN morbac.roles ro ON ro.id = r.role_id
    INNER JOIN morbac.contexts c ON c.id = r.context_id
    WHERE ur.user_id = p_user_id
      AND r.org_id = p_org_id
      AND ur.org_id = p_org_id
      AND r.modality = 'obligation'
      AND morbac.eval_context(r.context_id) = TRUE
    ORDER BY r.created_at;
END;
$$;

COMMENT ON FUNCTION morbac.pending_obligations(UUID, UUID) IS
'Returns pending obligations for a user in an organization (informational only)';

-- Recommendations for a user in an organization
CREATE OR REPLACE FUNCTION morbac.pending_recommendations(
    p_user_id UUID,
    p_org_id UUID
)
RETURNS TABLE(
    rule_id UUID,
    role_name TEXT,
    activity TEXT,
    view TEXT,
    context_name TEXT,
    created_at TIMESTAMPTZ
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    SELECT
        r.id,
        ro.name,
        r.activity,
        r.view,
        c.name,
        r.created_at
    FROM morbac.rules r
    INNER JOIN morbac.user_roles ur ON ur.role_id = r.role_id
    INNER JOIN morbac.roles ro ON ro.id = r.role_id
    INNER JOIN morbac.contexts c ON c.id = r.context_id
    WHERE ur.user_id = p_user_id
      AND r.org_id = p_org_id
      AND ur.org_id = p_org_id
      AND r.modality = 'recommendation'
      AND morbac.eval_context(r.context_id) = TRUE
    ORDER BY r.created_at;
END;
$$;

COMMENT ON FUNCTION morbac.pending_recommendations(UUID, UUID) IS
'Returns recommendations for a user in an organization (informational only)';

-- =============================================================================
-- 17. OBLIGATIONS FUNCTIONS
-- =============================================================================

-- Get all roles for a user in an organization
CREATE OR REPLACE FUNCTION morbac.user_roles_in_org(
    p_user_id UUID,
    p_org_id UUID
)
RETURNS TABLE(
    role_id UUID,
    role_name TEXT
)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN QUERY
    SELECT r.id, r.name
    FROM morbac.roles r
    INNER JOIN morbac.user_roles ur ON ur.role_id = r.id
    WHERE ur.user_id = p_user_id
      AND ur.org_id = p_org_id
      AND r.org_id = p_org_id;
END;
$$;

COMMENT ON FUNCTION morbac.user_roles_in_org(UUID, UUID) IS
'Returns all roles for a user in an organization';

-- Check if user has specific role in organization
CREATE OR REPLACE FUNCTION morbac.user_has_role(
    p_user_id UUID,
    p_org_id UUID,
    p_role_name TEXT
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_count INTEGER;
BEGIN
    SELECT COUNT(*) INTO v_count
    FROM morbac.roles r
    INNER JOIN morbac.user_roles ur ON ur.role_id = r.id
    WHERE ur.user_id = p_user_id
      AND ur.org_id = p_org_id
      AND r.org_id = p_org_id
      AND r.name = p_role_name;

    RETURN v_count > 0;
END;
$$;

COMMENT ON FUNCTION morbac.user_has_role(UUID, UUID, TEXT) IS
'Returns true if user has specific role in organization';

-- =============================================================================
-- 17a. ADMIN HELPER FUNCTIONS
-- =============================================================================

-- Helper: Check if user can assign/revoke roles
CREATE OR REPLACE FUNCTION morbac.can_manage_user_role(
    p_admin_user_id UUID,
    p_org_id UUID,
    p_target_role_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_role_name TEXT;
BEGIN
    -- Get role name
    SELECT name INTO v_role_name
    FROM morbac.roles
    WHERE id = p_target_role_id AND org_id = p_org_id;

    IF v_role_name IS NULL THEN
        RETURN FALSE;
    END IF;

    -- Check if admin has permission to manage this role
    RETURN morbac.is_admin_allowed(
        p_admin_user_id,
        p_org_id,
        'assign_role',
        v_role_name
    );
END;
$$;

COMMENT ON FUNCTION morbac.can_manage_user_role(UUID, UUID, UUID) IS
'Check if user can assign/revoke a specific role in organization';

-- Helper: Check if user can create/modify/delete roles
CREATE OR REPLACE FUNCTION morbac.can_manage_roles(
    p_user_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN morbac.is_admin_allowed(
        p_user_id,
        p_org_id,
        'manage',
        'roles'
    );
END;
$$;

COMMENT ON FUNCTION morbac.can_manage_roles(UUID, UUID) IS
'Check if user can create/modify/delete roles in organization';

-- Helper: Check if user can manage policies
CREATE OR REPLACE FUNCTION morbac.can_manage_policies(
    p_user_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    RETURN morbac.is_admin_allowed(
        p_user_id,
        p_org_id,
        'manage',
        'policies'
    );
END;
$$;

COMMENT ON FUNCTION morbac.can_manage_policies(UUID, UUID) IS
'Check if user can manage policies in organization';

-- Helper: Assign role to user (with permission check)
CREATE OR REPLACE FUNCTION morbac.admin_assign_role(
    p_admin_user_id UUID,
    p_target_user_id UUID,
    p_role_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
    -- Check if admin has permission
    IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
        RAISE EXCEPTION 'User % does not have permission to assign role % in org %',
            p_admin_user_id, p_role_id, p_org_id;
    END IF;

    -- Check SoD violations
    IF array_length(morbac.check_sod_violation(p_target_user_id, p_org_id), 1) > 0 THEN
        RAISE EXCEPTION 'Role assignment would violate Separation of Duty constraints';
    END IF;

    -- Assign role
    INSERT INTO morbac.user_roles (user_id, role_id, org_id)
    VALUES (p_target_user_id, p_role_id, p_org_id)
    ON CONFLICT (user_id, role_id, org_id) DO NOTHING;

    -- Check cardinality after assignment
    DECLARE
        v_cardinality_error TEXT;
    BEGIN
        v_cardinality_error := morbac.check_cardinality_violation(p_role_id, p_org_id);
        IF v_cardinality_error IS NOT NULL THEN
            RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_cardinality_error;
        END IF;
    END;

    RETURN TRUE;
END;
$$;

COMMENT ON FUNCTION morbac.admin_assign_role(UUID, UUID, UUID, UUID) IS
'Assign role to user with admin permission check and constraint validation';

-- Helper: Revoke role from user (with permission check)
CREATE OR REPLACE FUNCTION morbac.admin_revoke_role(
    p_admin_user_id UUID,
    p_target_user_id UUID,
    p_role_id UUID,
    p_org_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
AS $$
BEGIN
    -- Check if admin has permission
    IF NOT morbac.can_manage_user_role(p_admin_user_id, p_org_id, p_role_id) THEN
        RAISE EXCEPTION 'User % does not have permission to revoke role % in org %',
            p_admin_user_id, p_role_id, p_org_id;
    END IF;

    -- Revoke role
    DELETE FROM morbac.user_roles
    WHERE user_id = p_target_user_id
      AND role_id = p_role_id
      AND org_id = p_org_id;

    -- Check cardinality after revocation
    DECLARE
        v_cardinality_error TEXT;
    BEGIN
        v_cardinality_error := morbac.check_cardinality_violation(p_role_id, p_org_id);
        IF v_cardinality_error IS NOT NULL THEN
            RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_cardinality_error;
        END IF;
    END;

    RETURN TRUE;
END;
$$;

COMMENT ON FUNCTION morbac.admin_revoke_role(UUID, UUID, UUID, UUID) IS
'Revoke role from user with admin permission check';

-- =============================================================================
-- 18. UTILITY RESOURCE PATTERN (GUIDANCE)
-- =============================================================================
--
-- For resources that belong to multiple organizations:
--
-- 1. Create resource table (org-neutral):
--    CREATE TABLE app.documents (
--        id UUID PRIMARY KEY,
--        content TEXT,
--        ...
--    );
--
-- 2. Create organization membership table:
--    CREATE TABLE app.document_orgs (
--        document_id UUID REFERENCES app.documents(id),
--        org_id UUID REFERENCES morbac.orgs(id),
--        PRIMARY KEY (document_id, org_id)
--    );
--
-- 3. Apply RLS with multi-org support:
--    ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
--
--    CREATE POLICY document_select ON app.documents
--    FOR SELECT
--    USING (
--        EXISTS (
--            SELECT 1 FROM app.document_orgs do
--            WHERE do.document_id = app.documents.id
--              AND do.org_id = morbac.current_org_id()
--        )
--        AND morbac.rls_check('read', 'documents')
--    );
--
-- This pattern allows a resource to be visible in multiple organizations
-- while enforcing OrBAC policy within each organization context.
--
-- =============================================================================

-- =============================================================================
-- INSTALLATION COMPLETE
-- =============================================================================

-- Grant usage on schema to public (adjust based on your security requirements)
-- GRANT USAGE ON SCHEMA morbac TO public;
-- GRANT SELECT ON ALL TABLES IN SCHEMA morbac TO public;
-- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA morbac TO public;

-- For production, create specific roles and grant appropriate privileges
