Adds a first-class org target vocabulary shared by every rule kind: a specific organization, unattributed (objects whose org is NULL), or all. A role can now be granted the unassigned pile without a global rule. - rules.scope gains 'unattributed' and 'all' - user_rules.org_id accepts NULL to target unattributed objects - org_in_scope partitions the classes: 'unattributed' matches only a NULL target, tree scopes never match one - has_permission(user, activity, view) capability probe for UI gating - current_org_filter() parses morbac.org_ids once into org UUIDs plus the unattributed-bucket flag (a JSON null element requests it) - rls_check split by arity so NULL never carries two meanings: rls_check(activity, view) for tables with no org column, rls_check(activity, view, row_org_id[, row_user_id]) where a NULL row_org_id means the record is unattributed - detect_rule_conflicts is scope-aware, so rules targeting different object sets no longer collide
96 lines
4.2 KiB
PL/PgSQL
96 lines
4.2 KiB
PL/PgSQL
-- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality)
|
|
--
|
|
-- scope controls which orgs this rule covers relative to org_id:
|
|
-- 'self' — exact org only (default, current behavior)
|
|
-- 'subtree' — org + all descendants
|
|
-- 'descendants' — all descendants, excluding self
|
|
-- 'children' — direct children only
|
|
-- 'parent' — direct parent only
|
|
-- 'ancestors' — all ancestors, excluding self
|
|
-- 'lineage' — self + all ancestors
|
|
-- 'root' — topmost ancestor only
|
|
-- 'unattributed' — no-org objects only (org_id column above is the declaring authority)
|
|
-- 'all' — every org, unattributed included
|
|
-- Evaluated at query time via org_in_scope() — new orgs are picked up automatically.
|
|
--
|
|
-- Org target vocabulary: a specific org (the tree scopes), 'unattributed', or 'all'.
|
|
|
|
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,
|
|
scope TEXT NOT NULL DEFAULT 'self',
|
|
priority INTEGER,
|
|
valid_from TIMESTAMPTZ,
|
|
valid_until TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
|
metadata JSONB DEFAULT '{}'::jsonb,
|
|
UNIQUE(org_id, role_id, activity, view, context_id, modality, scope),
|
|
CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from),
|
|
CHECK (scope IN ('self', 'children', 'descendants', 'subtree', 'parent', 'ancestors', 'lineage', 'root', 'unattributed', 'all'))
|
|
);
|
|
|
|
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);
|
|
CREATE INDEX idx_rules_fast_lookup ON morbac.rules(org_id, activity, modality, view)
|
|
INCLUDE (role_id, context_id)
|
|
WHERE is_active = true;
|
|
|
|
COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation';
|
|
COMMENT ON COLUMN morbac.rules.scope IS 'Object scope: self (default), subtree, descendants, children, parent, ancestors, lineage, root, unattributed, all. Selects which objects (by org) the rule reaches: a specific org via the tree scopes, unattributed for no-org objects only, or all for every org including unattributed. Evaluated at query time — new orgs are covered automatically.';
|
|
COMMENT ON COLUMN morbac.rules.modality IS 'Deontic modality: permission, prohibition, obligation, recommendation';
|
|
COMMENT ON COLUMN morbac.rules.priority IS 'Optional rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.';
|
|
|
|
-- Trigger to maintain is_active based on temporal validity
|
|
|
|
CREATE OR REPLACE FUNCTION morbac.rules_set_is_active()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.is_active := (
|
|
(NEW.valid_from IS NULL OR NEW.valid_from <= CURRENT_TIMESTAMP)
|
|
AND (NEW.valid_until IS NULL OR NEW.valid_until > CURRENT_TIMESTAMP)
|
|
);
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_rules_set_is_active ON morbac.rules;
|
|
CREATE TRIGGER trg_rules_set_is_active
|
|
BEFORE INSERT OR UPDATE ON morbac.rules
|
|
FOR EACH ROW EXECUTE FUNCTION morbac.rules_set_is_active();
|
|
|
|
-- Check if a rule is currently valid based on temporal constraints
|
|
|
|
CREATE OR REPLACE FUNCTION morbac.is_rule_valid(
|
|
p_valid_from TIMESTAMPTZ,
|
|
p_valid_until TIMESTAMPTZ
|
|
)
|
|
RETURNS BOOLEAN
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
AS $$
|
|
DECLARE
|
|
v_now TIMESTAMPTZ := CURRENT_TIMESTAMP;
|
|
BEGIN
|
|
IF p_valid_from IS NOT NULL AND v_now < p_valid_from THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
IF p_valid_until IS NOT NULL AND v_now >= p_valid_until THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
RETURN TRUE;
|
|
END;
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION morbac.is_rule_valid(TIMESTAMPTZ, TIMESTAMPTZ) IS
|
|
'Check if a rule is currently valid based on temporal constraints';
|