From 145f50749be1915fc04dbe5343090b6a5399f986 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Fri, 27 Mar 2026 21:56:00 +0100 Subject: [PATCH] misc(all): cleanup comments, update documentation, tidy up code --- CONTRIBUTING.md | 12 ++-- README.md | 24 ++++---- SECURITY.md | 6 +- docs/DEVELOPMENT.md | 12 ++-- src/activities.sql | 14 +---- src/admin_helpers.sql | 24 +------- src/admin_rules.sql | 3 - src/audit.sql | 16 +----- src/auth_cache.sql | 19 ++----- src/authorization.sql | 22 ++------ src/config.sql | 9 --- src/contexts.sql | 19 +------ src/cross_org_rules.sql | 8 +-- src/footer.sql | 54 ++++++------------ src/header.sql | 19 ++----- src/hierarchy_functions.sql | 106 +++++++++--------------------------- src/materialized_views.sql | 10 +--- src/obligations.sql | 16 ++---- src/organizations.sql | 10 ---- src/policy_dsl.sql | 37 ++----------- src/rls.sql | 13 +---- src/roles.sql | 55 ++----------------- src/rules.sql | 39 +++---------- src/types.sql | 5 -- src/validation.sql | 16 ++---- src/views.sql | 14 +---- 26 files changed, 131 insertions(+), 451 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a961ee9..4a61b57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,9 +26,9 @@ Feature requests are welcome! Please open an issue describing: 2. **Create a feature branch**: `git checkout -b feature/my-feature` 3. **Make your changes**: - Follow the existing code style (SQL formatting, naming conventions) - - Add tests in `test_morbac.sql` for new features - - Update `DOCUMENTATION.md` if adding new functionality -4. **Test your changes**: `make test` + - Add tests in `tests/` for new features + - Update `docs/DOCUMENTATION.md` if adding new functionality +4. **Test your changes**: `make check` 5. **Commit with clear messages**: `git commit -m "Add feature: description"` 6. **Push to your fork**: `git push origin feature/my-feature` 7. **Open a Pull Request** with a description of your changes @@ -44,8 +44,8 @@ Feature requests are welcome! Please open an issue describing: ### Testing All changes must include tests: -- Add test cases to `test_morbac.sql` -- Ensure all existing tests still pass +- Add test cases in `tests/` following the numbered file convention +- Ensure all existing tests still pass (`make check`) - Test against PostgreSQL 13+ (mention version in PR) ### Documentation @@ -69,7 +69,7 @@ cd pgmorbac make install # Run tests -make test +make check ``` ## Questions? diff --git a/README.md b/README.md index d987469..f137008 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,17 @@ VALUES (org_id, role_id, 'write', 'sensitive_data', ctx_id, 'permission', '2024-01-01 00:00:00', '2024-12-31 23:59:59'); -- Delegation: Alice delegates role to Bob for 1 week -INSERT INTO morbac.delegations (delegator_user_id, delegate_user_id, role_id, org_id, valid_until) +INSERT INTO morbac.delegations (delegator_id, delegatee_id, role_id, org_id, valid_until) VALUES (alice_id, bob_id, role_id, org_id, NOW() + INTERVAL '7 days'); -- Separation of Duty: Invoice creator and approver roles are mutually exclusive -INSERT INTO morbac.sod_conflicts (role1_id, role2_id, org_id) +INSERT INTO morbac.sod_conflicts (role_a_id, role_b_id, org_id) VALUES (creator_role_id, approver_role_id, org_id); -- Cross-Org: Global auditor can access subsidiary -INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, modality) -VALUES (global_org_id, subsidiary_org_id, auditor_role_id, 'read', 'reports', 'permission'); +INSERT INTO morbac.cross_org_rules (source_org_id, target_org_id, role_id, activity, view, context_id, modality) +VALUES (global_org_id, subsidiary_org_id, auditor_role_id, 'read', 'reports', + (SELECT id FROM morbac.contexts WHERE name = 'always'), 'permission'); -- Audit logging: Track changes to user roles SELECT morbac.enable_audit('user_roles'); @@ -180,7 +181,7 @@ psql -d morbac_test -f pgmorbac.sql dropdb morbac_test ``` -See [src/README.md](src/README.md) for source code organization. +See [DEVELOPMENT.md](docs/DEVELOPMENT.md) for source code organization. ## Documentation @@ -198,11 +199,12 @@ Authorization decision flow: 1. Collect all roles for user (direct, delegated, derived, hierarchy) 2. Filter out negative role assignments -3. Check for prohibitions: if found, deny access -4. Check for permissions: if found, allow access -5. Default deny +3. Find the highest-priority applicable prohibition and highest-priority applicable permission +4. If a permission with strictly higher priority than the prohibition exists, allow +5. If a prohibition exists (and no higher-priority permission), deny +6. If no prohibition, allow if any permission was found; otherwise deny -Prohibitions always override permissions. +Prohibitions win over permissions at equal or unset priority. ## Research @@ -223,12 +225,12 @@ cd pgmorbac vim src/authorization.sql # Build and test your changes -make test # Run test suite +make check # Run test suite # When ready to release # 1. Update version in pgmorbac.control # 2. Build versioned file -make build # Creates pgmorbac--X.Y.Z.sql +make build # Creates pgmorbac.sql from src/ # 3. Tag in git git tag v1.0.1 git push --tags diff --git a/SECURITY.md b/SECURITY.md index a023bd4..aaa6f34 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,9 +15,9 @@ pgmorbac implements the Multi-OrBAC access control model with the following secu - Explicit permissions must be granted ### Prohibition Precedence -- Prohibitions are checked first -- Prohibitions always override permissions -- Early return for performance and security +- Prohibitions and permissions are both evaluated; the higher-priority rule wins +- At equal or unset priority, prohibition overrides permission +- A permission with strictly higher priority than a prohibition will override it ### Organization Isolation - All policies are scoped to organizations diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9e8d425..f92e099 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,7 +30,7 @@ vim src/authorization.sql ### Build and Test ```bash -make test +make check ``` This builds from `src/`, creates a temporary database, runs all tests, and cleans up. @@ -58,7 +58,7 @@ dropdb mytest 1. Update version in `pgmorbac.control` 2. Update `CHANGELOG.md` -3. Run `make build` and `make test` +3. Run `make build` and `make check` 4. Commit and tag: ```bash @@ -71,7 +71,7 @@ git push origin main --tags ```bash make build # Build from src/ files -make test # Build and run tests +make check # Build and run tests make install # Install to PostgreSQL make help # Show all targets ``` @@ -87,14 +87,14 @@ Functions use `verb_noun` pattern. Tables use plural nouns. 1. Edit files in `src/` 2. Add tests in `tests/` 3. Update `docs/DOCUMENTATION.md` -4. Run `make test` +4. Run `make check` 5. Commit source files ## Debugging ```sql -SELECT * FROM morbac.auth_cache ORDER BY last_checked DESC LIMIT 10; -SELECT morbac.invalidate_auth_cache(); +SELECT * FROM morbac.auth_cache ORDER BY computed_at DESC LIMIT 10; +SELECT morbac.invalidate_cache(); EXPLAIN ANALYZE SELECT morbac.is_allowed(...); SELECT morbac.refresh_hierarchy_cache(); ``` diff --git a/src/activities.sql b/src/activities.sql index 4b7f5ca..ff14cbb 100644 --- a/src/activities.sql +++ b/src/activities.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- ACTIVITIES --- ============================================================================= --- Activities represent abstract actions in OrBAC --- These are global abstractions (not org-scoped) +-- Activities are global abstract actions (not org-scoped) CREATE TABLE morbac.activities ( name TEXT PRIMARY KEY, @@ -11,14 +7,8 @@ CREATE TABLE morbac.activities ( ); COMMENT ON TABLE morbac.activities IS 'Activities - abstract actions in OrBAC model (global)'; -COMMENT ON COLUMN morbac.activities.name IS 'Activity name (unique, global)'; - --- ============================================================================= --- ACTIVITY HIERARCHY --- ============================================================================= --- Activities can inherit from other activities --- e.g., "write" implies "read", "admin_delete" implies "delete" +-- Senior activities imply junior activities (e.g., write implies read) 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, diff --git a/src/admin_helpers.sql b/src/admin_helpers.sql index 11b574e..5a9f2a2 100644 --- a/src/admin_helpers.sql +++ b/src/admin_helpers.sql @@ -1,8 +1,3 @@ --- ============================================================================= --- 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, @@ -15,7 +10,6 @@ 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; @@ -24,7 +18,6 @@ BEGIN 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, @@ -37,7 +30,6 @@ $$; 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 @@ -59,7 +51,6 @@ $$; 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 @@ -81,7 +72,6 @@ $$; 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, @@ -92,27 +82,23 @@ 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 + IF morbac.check_sod_violation(p_target_user_id, p_role_id, p_org_id) 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); + v_cardinality_error := morbac.check_cardinality_violation(p_role_id); IF v_cardinality_error IS NOT NULL THEN RAISE EXCEPTION 'Role assignment violates cardinality constraint: %', v_cardinality_error; END IF; @@ -125,7 +111,6 @@ $$; 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, @@ -136,23 +121,20 @@ 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); + v_cardinality_error := morbac.check_cardinality_violation(p_role_id, FALSE); IF v_cardinality_error IS NOT NULL THEN RAISE WARNING 'Role revocation may violate cardinality constraint: %', v_cardinality_error; END IF; diff --git a/src/admin_rules.sql b/src/admin_rules.sql index 3c5914d..c6d6207 100644 --- a/src/admin_rules.sql +++ b/src/admin_rules.sql @@ -1,6 +1,3 @@ --- ============================================================================= --- ADMINISTRATION RULES --- ============================================================================= -- Meta-policies defining who can create/modify policies (AdministrationPermission) CREATE TABLE morbac.admin_rules ( diff --git a/src/audit.sql b/src/audit.sql index 94ebcae..3b8d875 100644 --- a/src/audit.sql +++ b/src/audit.sql @@ -1,8 +1,5 @@ --- ============================================================================= --- AUDIT LOG --- ============================================================================= --- Optional audit logging for tracking changes to security-critical tables --- Enable/disable per table with triggers +-- Optional audit logging for tracking changes to security-critical tables. +-- Enable/disable per table with morbac.enable_audit() / morbac.disable_audit(). CREATE TABLE morbac.audit_log ( id BIGSERIAL PRIMARY KEY, @@ -35,7 +32,6 @@ COMMENT ON COLUMN morbac.audit_log.changed_fields IS 'Array of field names that COMMENT ON COLUMN morbac.audit_log.session_username IS 'Database session user'; COMMENT ON COLUMN morbac.audit_log.client_addr IS 'Client IP address'; --- Generic audit trigger function CREATE OR REPLACE FUNCTION morbac.audit_trigger() RETURNS TRIGGER LANGUAGE plpgsql @@ -48,7 +44,6 @@ DECLARE v_org_id UUID; v_record_id UUID; BEGIN - -- Try to get current user/org context BEGIN v_user_id := morbac.current_user_id(); EXCEPTION WHEN OTHERS THEN @@ -61,7 +56,6 @@ BEGIN v_org_id := NULL; END; - -- Handle different operations IF TG_OP = 'DELETE' THEN v_old_data := row_to_json(OLD)::jsonb; v_new_data := NULL; @@ -75,14 +69,12 @@ BEGIN v_new_data := row_to_json(NEW)::jsonb; v_record_id := (v_new_data->>'id')::uuid; - -- Identify changed fields SELECT array_agg(key) INTO v_changed_fields FROM jsonb_each(v_old_data) WHERE v_old_data->key IS DISTINCT FROM v_new_data->key; END IF; - -- Override org_id from record if available IF v_org_id IS NULL THEN IF v_new_data ? 'org_id' THEN v_org_id := (v_new_data->>'org_id')::uuid; @@ -91,7 +83,6 @@ BEGIN END IF; END IF; - -- Insert audit record INSERT INTO morbac.audit_log ( user_id, org_id, @@ -112,7 +103,6 @@ BEGIN v_changed_fields ); - -- Return appropriate value IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE @@ -124,7 +114,6 @@ $$; COMMENT ON FUNCTION morbac.audit_trigger() IS 'Generic audit trigger function - captures INSERT/UPDATE/DELETE operations'; --- Helper function to enable audit logging on a table CREATE OR REPLACE FUNCTION morbac.enable_audit(p_table_name TEXT) RETURNS VOID LANGUAGE plpgsql @@ -148,7 +137,6 @@ $$; COMMENT ON FUNCTION morbac.enable_audit(TEXT) IS 'Enable audit logging on a morbac table - creates audit trigger'; --- Helper function to disable audit logging on a table CREATE OR REPLACE FUNCTION morbac.disable_audit(p_table_name TEXT) RETURNS VOID LANGUAGE plpgsql diff --git a/src/auth_cache.sql b/src/auth_cache.sql index 98addec..6f282d3 100644 --- a/src/auth_cache.sql +++ b/src/auth_cache.sql @@ -1,8 +1,5 @@ --- ============================================================================= --- PERFORMANCE: AUTHORIZATION CACHE --- ============================================================================= --- Cache authorization decisions to avoid repeated expensive computations --- Cache TTL is configurable via morbac.config table (key: cache_ttl_seconds) +-- Authorization decision cache to avoid repeated expensive computations. +-- TTL is configurable via morbac.config (cache_ttl_seconds). CREATE TABLE morbac.auth_cache ( user_id UUID NOT NULL, @@ -21,7 +18,6 @@ CREATE INDEX idx_auth_cache_user_org ON morbac.auth_cache(user_id, org_id); COMMENT ON TABLE morbac.auth_cache IS 'Authorization decision cache - expires after 5 minutes or when policies change'; --- Invalidate cache for user/org CREATE OR REPLACE FUNCTION morbac.invalidate_cache(p_user_id UUID DEFAULT NULL, p_org_id UUID DEFAULT NULL) RETURNS VOID LANGUAGE plpgsql @@ -42,7 +38,6 @@ $$; COMMENT ON FUNCTION morbac.invalidate_cache(UUID, UUID) IS 'Invalidate auth cache for specific user/org or all entries'; --- Auto cleanup expired cache entries CREATE OR REPLACE FUNCTION morbac.cleanup_auth_cache() RETURNS INTEGER LANGUAGE plpgsql @@ -59,7 +54,8 @@ $$; COMMENT ON FUNCTION morbac.cleanup_auth_cache() IS 'Remove expired cache entries - call periodically via cron'; --- Trigger to invalidate cache on rule changes +-- Invalidate cache on rule or role changes + CREATE OR REPLACE FUNCTION morbac.invalidate_cache_on_change() RETURNS TRIGGER LANGUAGE plpgsql @@ -67,7 +63,6 @@ AS $$ DECLARE v_org_id UUID; BEGIN - -- Only clear cache if org_id exists in NEW or OLD IF TG_OP = 'DELETE' THEN BEGIN v_org_id := OLD.org_id; @@ -88,7 +83,6 @@ BEGIN END; $$; --- Attach cache invalidation triggers CREATE TRIGGER trg_invalidate_cache_rules AFTER INSERT OR UPDATE OR DELETE ON morbac.rules FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); @@ -105,15 +99,14 @@ CREATE TRIGGER trg_invalidate_cache_cross_org AFTER INSERT OR UPDATE OR DELETE ON morbac.cross_org_rules FOR EACH ROW EXECUTE FUNCTION morbac.invalidate_cache_on_change(); --- Trigger to refresh hierarchies when they change +-- Refresh materialized hierarchy views when hierarchies change + CREATE OR REPLACE FUNCTION morbac.refresh_on_hierarchy_change() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN - -- Refresh in background (this will block briefly but necessary) PERFORM morbac.refresh_hierarchy_cache(); - -- Also invalidate auth cache since hierarchies affect authorization DELETE FROM morbac.auth_cache; RETURN COALESCE(NEW, OLD); END; diff --git a/src/authorization.sql b/src/authorization.sql index acdba3e..67f5c3d 100644 --- a/src/authorization.sql +++ b/src/authorization.sql @@ -1,19 +1,15 @@ --- ============================================================================= --- AUTHORIZATION DECISION FUNCTION (CRITICAL) --- ============================================================================= --- Implements the canonical OrBAC authorization decision +-- Authorization decision function implementing canonical OrBAC semantics. -- --- Semantics (from Multi-OrBAC paper): --- - Access is allowed if and only if: +-- Access is allowed if and only if: -- 1. At least one applicable permission exists -- 2. AND no applicable prohibition exists (or permission has strictly higher priority) --- - Default deny (no permission = deny) --- - Obligations and recommendations do NOT affect authorization +-- Default deny (no permission = deny). +-- Obligations and recommendations do NOT affect authorization. -- -- Priority resolution: -- - Each rule has an optional integer priority (NULL = 0, lowest) -- - When both a prohibition and a permission apply, the higher-priority rule wins --- - Tie goes to prohibition (modality precedence from the paper) +-- - Tie goes to prohibition (modality precedence from the Multi-OrBAC paper) CREATE OR REPLACE FUNCTION morbac.is_allowed_nocache( p_user_id UUID, @@ -44,7 +40,7 @@ BEGIN LOOP IF morbac.eval_context(v_rule.context_id) THEN v_max_prohibition_priority := v_rule.prio; - EXIT; -- Highest-priority prohibition found; lower ones can't change the outcome + EXIT; END IF; END LOOP; @@ -107,18 +103,15 @@ BEGIN END LOOP; -- STEP 5: Priority resolution - -- No prohibition at all: allow if any permission was found IF v_max_prohibition_priority IS NULL THEN RETURN v_max_permission_priority IS NOT NULL; END IF; - -- Prohibition exists: a permission with strictly higher priority overrides it IF v_max_permission_priority IS NOT NULL AND v_max_permission_priority > v_max_prohibition_priority THEN RETURN TRUE; END IF; - -- Prohibition wins (no permission, equal priority, or lower-priority permission) RETURN FALSE; END; $$; @@ -142,7 +135,6 @@ DECLARE v_computed_result BOOLEAN; v_expires_at TIMESTAMPTZ; BEGIN - -- Try cache first SELECT allowed, expires_at INTO v_cached_result, v_expires_at FROM morbac.auth_cache WHERE user_id = p_user_id @@ -155,10 +147,8 @@ BEGIN RETURN v_cached_result; END IF; - -- Cache miss - compute authorization v_computed_result := morbac.is_allowed_nocache(p_user_id, p_org_id, p_activity, p_view); - -- Store in cache (TTL from config) INSERT INTO morbac.auth_cache (user_id, org_id, activity, view, allowed, expires_at) VALUES ( p_user_id, diff --git a/src/config.sql b/src/config.sql index 1aebae7..4e51464 100644 --- a/src/config.sql +++ b/src/config.sql @@ -1,9 +1,3 @@ --- ============================================================================= --- CONFIGURATION --- ============================================================================= --- Centralized configuration for pgmorbac extension --- Edit these values to customize behavior - CREATE TABLE morbac.config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -13,13 +7,11 @@ CREATE TABLE morbac.config ( COMMENT ON TABLE morbac.config IS 'Extension configuration - edit values to customize behavior'; --- Insert default configuration values INSERT INTO morbac.config (key, value, description) VALUES ('cache_ttl_seconds', '300', 'Authorization cache time-to-live in seconds (default: 5 minutes)'), ('hierarchy_max_depth', '10', 'Maximum depth for hierarchy traversal to prevent infinite loops'), ('enable_audit_by_default', 'false', 'Whether to enable audit logging by default on installation'); --- Helper function to get configuration values CREATE OR REPLACE FUNCTION morbac.get_config(p_key TEXT) RETURNS TEXT LANGUAGE plpgsql @@ -39,7 +31,6 @@ $$; COMMENT ON FUNCTION morbac.get_config(TEXT) IS 'Get configuration value by key'; --- Helper function to set configuration values CREATE OR REPLACE FUNCTION morbac.set_config(p_key TEXT, p_value TEXT) RETURNS VOID LANGUAGE plpgsql diff --git a/src/contexts.sql b/src/contexts.sql index b7a8a40..6170200 100644 --- a/src/contexts.sql +++ b/src/contexts.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- CONTEXTS --- ============================================================================= --- Contexts represent conditions under which rules apply --- Implemented as callable predicates (functions) +-- Contexts represent conditions under which rules apply, implemented as callable predicates CREATE TABLE morbac.contexts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -15,13 +11,9 @@ CREATE TABLE morbac.contexts ( 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)'; --- ============================================================================= --- DEFAULT CONTEXT: ALWAYS --- ============================================================================= --- Create a default context that always evaluates to true +-- Default context that always evaluates to true CREATE OR REPLACE FUNCTION morbac.context_always() RETURNS BOOLEAN @@ -35,17 +27,12 @@ $$; 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 ); --- ============================================================================= --- CONTEXT EVALUATION HELPER --- ============================================================================= --- Evaluates a context by calling its evaluator function CREATE OR REPLACE FUNCTION morbac.eval_context(p_context_id UUID) RETURNS BOOLEAN @@ -56,7 +43,6 @@ 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; @@ -65,7 +51,6 @@ BEGIN 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); diff --git a/src/cross_org_rules.sql b/src/cross_org_rules.sql index 67595b4..4b2adc2 100644 --- a/src/cross_org_rules.sql +++ b/src/cross_org_rules.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- INTER-ORGANIZATIONAL RULES --- ============================================================================= --- Rules that apply across organizations (cross-org access) --- Allows users from one org to access resources in another org +-- Rules that grant access across organization boundaries CREATE TABLE morbac.cross_org_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -30,5 +26,3 @@ CREATE INDEX idx_cross_org_rules_temporal ON morbac.cross_org_rules(valid_from, 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'; -COMMENT ON COLUMN morbac.cross_org_rules.valid_from IS 'Optional start time for rule validity'; -COMMENT ON COLUMN morbac.cross_org_rules.valid_until IS 'Optional end time for rule validity'; diff --git a/src/footer.sql b/src/footer.sql index fa1ef0d..7bb5ace 100644 --- a/src/footer.sql +++ b/src/footer.sql @@ -1,49 +1,31 @@ --- ============================================================================= --- UTILITY RESOURCE PATTERN (GUIDANCE) --- ============================================================================= +-- Multi-org resource pattern (guidance): -- -- For resources that belong to multiple organizations: -- --- Create resource table (org-neutral): --- CREATE TABLE app.documents ( --- id UUID PRIMARY KEY, --- content TEXT, --- ... --- ); +-- CREATE TABLE app.documents (id UUID PRIMARY KEY, content TEXT, ...); -- --- 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) --- ); +-- 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) +-- ); -- --- Apply RLS with multi-org support: --- ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY; +-- 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') --- ); +-- 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 +-- This 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 diff --git a/src/header.sql b/src/header.sql index 28a39f8..36a9219 100644 --- a/src/header.sql +++ b/src/header.sql @@ -1,14 +1,5 @@ --- ============================================================================= --- pgmorbac Extension --- ============================================================================= --- Multi-OrBAC: Organization-Based Access Control with Multi-Organization Support --- Based on the CNRS research paper on Multi-OrBAC model --- --- This extension implements: --- - Role-Based Access Control (RBAC) with organizational scoping --- - Hierarchical organizations, roles, activities, and views --- - Permission, Prohibition, Obligation, and Recommendation deontic modalities --- - Temporal constraints on rules --- - Contextual access control --- - Role delegation with time bounds --- ============================================================================= +-- pgmorbac: Multi-OrBAC for PostgreSQL +-- Organization-Based Access Control with multi-organization support, +-- role/activity/view hierarchies, deontic modalities, and temporal constraints. +-- Based on the CNRS Multi-OrBAC research model. + diff --git a/src/hierarchy_functions.sql b/src/hierarchy_functions.sql index 68dd7d8..1b9d024 100644 --- a/src/hierarchy_functions.sql +++ b/src/hierarchy_functions.sql @@ -1,9 +1,5 @@ --- ============================================================================= --- HIERARCHY FUNCTIONS --- ============================================================================= --- Functions to compute transitive closures for organization and role hierarchies +-- Transitive closure and effective role/activity/view lookup functions --- 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 @@ -12,10 +8,8 @@ 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 @@ -28,7 +22,6 @@ $$; 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 @@ -37,10 +30,8 @@ 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 @@ -52,7 +43,7 @@ $$; COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS 'Returns all descendant organizations (including self) with depth in hierarchy'; --- Get a named scope of organizations relative to a given org +-- Get a named scope of organizations relative to a given org. -- -- Supported scopes: -- 'self' — the org itself only (depth = 0) @@ -65,8 +56,6 @@ COMMENT ON FUNCTION morbac.get_org_descendants(UUID) IS -- 'root' — topmost ancestor only (max depth ancestor) -- -- Optional p_max_depth limits how many levels are traversed (NULL = unlimited). --- For upward scopes (parent/ancestors/lineage/root) depth counts steps toward root. --- For downward scopes (children/descendants/subtree) depth counts steps toward leaves. CREATE OR REPLACE FUNCTION morbac.get_org_scope( p_org_id UUID, p_scope TEXT, @@ -138,7 +127,6 @@ $$; COMMENT ON FUNCTION morbac.get_org_scope(UUID, TEXT, INTEGER) IS 'Returns a named set of organizations relative to p_org_id. Scopes: self, children, descendants, subtree, parent, ancestors, lineage, root. Optional p_max_depth limits traversal depth.'; --- 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 @@ -147,13 +135,11 @@ 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: junior roles (inherited by the user's assigned roles) SELECT rh.junior_role_id, er.depth + 1 FROM effective_roles er INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id @@ -167,7 +153,6 @@ $$; 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 @@ -176,10 +161,8 @@ 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 @@ -193,7 +176,6 @@ $$; 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 @@ -202,10 +184,8 @@ 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 @@ -219,7 +199,6 @@ $$; 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 @@ -228,10 +207,8 @@ 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 @@ -245,7 +222,6 @@ $$; 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 @@ -259,7 +235,6 @@ BEGIN 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 @@ -269,34 +244,32 @@ BEGIN 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 - ) + -- 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 + 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 + ) + 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 + UNION - -- Role hierarchy (junior roles inherited by user's roles) - SELECT rh.junior_role_id, er.source || '_inherited', er.depth + 1 - FROM effective_roles er - INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id + -- Role hierarchy (junior roles inherited by user's roles) + SELECT rh.junior_role_id, er.source || '_inherited', er.depth + 1 + FROM effective_roles er + INNER JOIN morbac.role_hierarchy rh ON rh.senior_role_id = er.role_id ) SELECT DISTINCT ON (effective_roles.role_id) effective_roles.role_id, effective_roles.source, effective_roles.depth FROM effective_roles @@ -322,7 +295,6 @@ $$; 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, @@ -345,29 +317,3 @@ $$; 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 (inherited_roles.role_id) inherited_roles.role_id, inherited_roles.depth - FROM inherited_roles - ORDER BY inherited_roles.role_id, inherited_roles.depth; -END; -$$; - -COMMENT ON FUNCTION morbac.get_inherited_roles(UUID) IS -'Returns all roles inherited by a role (transitive closure via role hierarchy)'; diff --git a/src/materialized_views.sql b/src/materialized_views.sql index 45419ab..c0d19df 100644 --- a/src/materialized_views.sql +++ b/src/materialized_views.sql @@ -1,9 +1,5 @@ --- ============================================================================= --- PERFORMANCE: MATERIALIZED HIERARCHY VIEWS --- ============================================================================= --- Precomputed transitive closures for hierarchies to avoid recursive CTEs on every request +-- Precomputed transitive closures for all hierarchies to avoid recursive CTEs on each request --- Precomputed role hierarchy transitive closure CREATE MATERIALIZED VIEW morbac.mv_role_closure AS WITH RECURSIVE role_closure AS ( SELECT id as senior_role_id, id as junior_role_id, 0 as depth @@ -27,7 +23,6 @@ CREATE INDEX idx_mv_role_closure_junior ON morbac.mv_role_closure(junior_role_id COMMENT ON MATERIALIZED VIEW morbac.mv_role_closure IS 'Precomputed role hierarchy transitive closure - refresh after role hierarchy changes'; --- Precomputed org hierarchy transitive closure CREATE MATERIALIZED VIEW morbac.mv_org_closure AS WITH RECURSIVE org_closure AS ( SELECT id as descendant_id, id as ancestor_id, 0 as depth @@ -48,7 +43,6 @@ CREATE INDEX idx_mv_org_closure_ancestor ON morbac.mv_org_closure(ancestor_id); COMMENT ON MATERIALIZED VIEW morbac.mv_org_closure IS 'Precomputed organization hierarchy transitive closure - refresh after org hierarchy changes'; --- Precomputed activity hierarchy CREATE MATERIALIZED VIEW morbac.mv_activity_closure AS WITH RECURSIVE activity_closure AS ( SELECT name as senior_activity, name as junior_activity, 0 as depth @@ -72,7 +66,6 @@ CREATE INDEX idx_mv_activity_closure_junior ON morbac.mv_activity_closure(junior COMMENT ON MATERIALIZED VIEW morbac.mv_activity_closure IS 'Precomputed activity hierarchy transitive closure - refresh after activity hierarchy changes'; --- Precomputed view hierarchy CREATE MATERIALIZED VIEW morbac.mv_view_closure AS WITH RECURSIVE view_closure AS ( SELECT name as senior_view, name as junior_view, 0 as depth @@ -96,7 +89,6 @@ CREATE INDEX idx_mv_view_closure_junior ON morbac.mv_view_closure(junior_view); COMMENT ON MATERIALIZED VIEW morbac.mv_view_closure IS 'Precomputed view hierarchy transitive closure - refresh after view hierarchy changes'; --- Helper function to refresh all materialized views CREATE OR REPLACE FUNCTION morbac.refresh_hierarchy_cache() RETURNS VOID LANGUAGE plpgsql diff --git a/src/obligations.sql b/src/obligations.sql index 9dce187..b8e0896 100644 --- a/src/obligations.sql +++ b/src/obligations.sql @@ -1,16 +1,11 @@ --- ============================================================================= --- OBLIGATIONS AND RECOMMENDATIONS --- ============================================================================= --- Obligations and recommendations do NOT affect authorization --- They are queryable for informational purposes +-- Obligations and recommendations do NOT affect authorization decisions. +-- They are queryable for informational purposes (task lists, compliance prompts, etc.). -- -- Conflict resolution (from Multi-OrBAC paper): -- - A prohibition voids any applicable obligation for the same (activity, view) -- - A prohibition or obligation voids any applicable recommendation for the same (activity, view) --- - Both functions use comprehensive roles (direct, delegated, derived, inherited) --- Pending obligations for a user in an organization --- Returns only obligations not voided by an applicable prohibition +-- Returns pending obligations not voided by an applicable prohibition CREATE OR REPLACE FUNCTION morbac.pending_obligations( p_user_id UUID, p_org_id UUID @@ -67,8 +62,7 @@ $$; COMMENT ON FUNCTION morbac.pending_obligations(UUID, UUID) IS 'Returns pending obligations for a user in an organization (informational only). Prohibitions void applicable obligations per Multi-OrBAC conflict resolution.'; --- Recommendations for a user in an organization --- Returns only recommendations not voided by an applicable prohibition or obligation +-- Returns recommendations not voided by an applicable prohibition or obligation CREATE OR REPLACE FUNCTION morbac.pending_recommendations( p_user_id UUID, p_org_id UUID @@ -125,7 +119,6 @@ $$; COMMENT ON FUNCTION morbac.pending_recommendations(UUID, UUID) IS 'Returns recommendations for a user in an organization (informational only). Prohibitions and obligations void applicable recommendations per Multi-OrBAC conflict resolution.'; --- 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 @@ -151,7 +144,6 @@ $$; 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, diff --git a/src/organizations.sql b/src/organizations.sql index 8ce0758..a95899f 100644 --- a/src/organizations.sql +++ b/src/organizations.sql @@ -1,9 +1,3 @@ --- ============================================================================= --- 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, @@ -16,7 +10,3 @@ 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'; diff --git a/src/policy_dsl.sql b/src/policy_dsl.sql index c026198..3d24b4e 100644 --- a/src/policy_dsl.sql +++ b/src/policy_dsl.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- POLICY DSL TABLE --- ============================================================================= --- Simplified table for developers to declare policy --- Uses friendly names instead of UUIDs +-- Simplified policy declaration using names instead of UUIDs CREATE TABLE morbac.policy ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -17,23 +13,16 @@ CREATE TABLE morbac.policy ( compiled BOOLEAN NOT NULL DEFAULT FALSE ); --- UNIQUE including priority: NULL treated as -1 so two NULL-priority rows for the same tuple conflict +-- NULL priority treated as -1 so two NULL-priority rows for the same tuple conflict CREATE UNIQUE INDEX idx_policy_unique ON morbac.policy(org_name, role_name, activity, view, modality, context_name, COALESCE(priority, -1)); 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.priority IS 'Optional rule priority (higher wins over lower; NULL = 0)'; COMMENT ON COLUMN morbac.policy.compiled IS 'Whether this policy entry has been compiled into rules'; +COMMENT ON COLUMN morbac.policy.priority IS 'Optional rule priority (higher wins over lower; NULL = 0)'; --- Helper function to check administration permissions CREATE OR REPLACE FUNCTION morbac.is_admin_allowed( p_user_id UUID, p_org_id UUID, @@ -47,7 +36,6 @@ AS $$ DECLARE v_rule RECORD; BEGIN - -- Check for admin prohibitions first FOR v_rule IN SELECT ar.context_id FROM morbac.admin_rules ar @@ -64,7 +52,6 @@ BEGIN END IF; END LOOP; - -- Check for admin permissions FOR v_rule IN SELECT ar.context_id FROM morbac.admin_rules ar @@ -81,19 +68,15 @@ BEGIN END IF; END LOOP; - RETURN FALSE; -- Default deny + RETURN FALSE; END; $$; COMMENT ON FUNCTION morbac.is_admin_allowed(UUID, UUID, TEXT, TEXT) IS 'Checks administration permissions for policy management operations'; --- ============================================================================= --- POLICY COMPILER --- ============================================================================= --- Translates policy DSL entries into concrete rules --- Resolves names to IDs --- Idempotent - safe to run multiple times +-- Translates policy DSL entries into concrete rules by resolving names to IDs. +-- Idempotent - safe to run multiple times. CREATE OR REPLACE FUNCTION morbac.compile_policy() RETURNS TABLE( @@ -112,42 +95,34 @@ DECLARE 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, priority) VALUES (v_org_id, v_role_id, v_policy.activity, v_policy.view, v_context_id, v_policy.modality, v_policy.priority) 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; diff --git a/src/rls.sql b/src/rls.sql index 537ca09..5a95cd0 100644 --- a/src/rls.sql +++ b/src/rls.sql @@ -1,10 +1,5 @@ --- ============================================================================= --- RLS HELPER FUNCTIONS --- ============================================================================= --- Helper functions for Row-Level Security policies --- Compatible with PostgREST +-- Row-Level Security helpers, compatible with PostgREST --- Get current user ID from session variable CREATE OR REPLACE FUNCTION morbac.current_user_id() RETURNS UUID LANGUAGE plpgsql @@ -13,7 +8,6 @@ AS $$ DECLARE v_user_id TEXT; BEGIN - -- Read from morbac.user_id GUC (set via SET morbac.user_id = '...' or PostgREST header mapping) v_user_id := current_setting('morbac.user_id', TRUE); IF v_user_id IS NULL OR v_user_id = '' THEN @@ -30,7 +24,6 @@ $$; COMMENT ON FUNCTION morbac.current_user_id() IS 'Returns current user ID from morbac.user_id session variable'; --- Get current organization ID from session variable CREATE OR REPLACE FUNCTION morbac.current_org_id() RETURNS UUID LANGUAGE plpgsql @@ -39,7 +32,6 @@ AS $$ DECLARE v_org_id TEXT; BEGIN - -- Read from morbac.org_id GUC (set via SET morbac.org_id = '...' or PostgREST header mapping) v_org_id := current_setting('morbac.org_id', TRUE); IF v_org_id IS NULL OR v_org_id = '' THEN @@ -56,7 +48,6 @@ $$; COMMENT ON FUNCTION morbac.current_org_id() IS 'Returns current organization ID from morbac.org_id session variable'; --- RLS check function CREATE OR REPLACE FUNCTION morbac.rls_check( p_activity TEXT, p_view TEXT @@ -72,12 +63,10 @@ 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 cached authorization decision function (default behavior) RETURN morbac.is_allowed(v_user_id, v_org_id, p_activity, p_view); END; $$; diff --git a/src/roles.sql b/src/roles.sql index 570636f..2d1d313 100644 --- a/src/roles.sql +++ b/src/roles.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- ROLES --- ============================================================================= --- Roles are scoped to organizations --- A role abstracts a set of subjects within an organization +-- Roles are scoped to organizations and abstract sets of subjects CREATE TABLE morbac.roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -17,13 +13,7 @@ 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)'; --- ============================================================================= --- ROLE HIERARCHY --- ============================================================================= --- Roles can inherit from other roles (role hierarchy) -- Senior roles inherit permissions from junior roles CREATE TABLE morbac.role_hierarchy ( @@ -41,11 +31,7 @@ COMMENT ON TABLE morbac.role_hierarchy IS 'Role hierarchy - senior roles inherit 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)'; --- ============================================================================= --- USER-ROLE ASSIGNMENTS --- ============================================================================= --- Maps users to roles within organizations --- user_id is external (e.g., from authentication system) +-- Maps users to roles within organizations; user_id is external (e.g., from auth system) CREATE TABLE morbac.user_roles ( user_id UUID NOT NULL, @@ -57,19 +43,12 @@ CREATE TABLE morbac.user_roles ( 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); --- Performance: Fast role lookup with included role_id CREATE INDEX idx_user_roles_fast ON morbac.user_roles(user_id, org_id) INCLUDE (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'; --- ============================================================================= --- DELEGATION --- ============================================================================= --- Users can delegate their permissions to other users temporarily --- Delegation is time-bounded and role-scoped +-- Temporary delegation of a role from one user to another, time-bounded CREATE TABLE morbac.delegations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -89,18 +68,11 @@ CREATE INDEX idx_delegations_delegatee ON morbac.delegations(delegatee_id, org_i 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'; --- ============================================================================= --- NEGATIVE ROLE ASSIGNMENTS --- ============================================================================= --- Explicitly prevent users from ever getting certain roles --- Takes precedence over positive 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, @@ -114,14 +86,9 @@ CREATE TABLE morbac.negative_role_assignments ( 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'; --- ============================================================================= --- SEPARATION OF DUTY (SoD) --- ============================================================================= --- Define mutually exclusive roles that cannot be held simultaneously +-- Mutually exclusive roles that cannot be held simultaneously CREATE TABLE morbac.sod_conflicts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -138,14 +105,8 @@ 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'; --- ============================================================================= --- CARDINALITY CONSTRAINTS --- ============================================================================= --- Limit the number of users that can have a specific role +-- Min/max user count constraints per role CREATE TABLE morbac.role_cardinality ( role_id UUID PRIMARY KEY REFERENCES morbac.roles(id) ON DELETE CASCADE, @@ -161,9 +122,6 @@ COMMENT ON TABLE morbac.role_cardinality IS 'Cardinality constraints for roles ( 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'; --- ============================================================================= --- DERIVED ROLES --- ============================================================================= -- Roles computed dynamically based on conditions rather than explicit assignment CREATE TABLE morbac.derived_roles ( @@ -174,5 +132,4 @@ CREATE TABLE morbac.derived_roles ( ); 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'; diff --git a/src/rules.sql b/src/rules.sql index 09de5df..0bec674 100644 --- a/src/rules.sql +++ b/src/rules.sql @@ -1,11 +1,4 @@ --- ============================================================================= --- RULES (Core OrBAC Policy) --- ============================================================================= --- Implements the OrBAC rule relation: --- Rule(org, role, activity, view, context, modality) --- --- Represents: Permission, Prohibition, Obligation, or Recommendation - +-- Core OrBAC rule relation: Rule(org, role, activity, view, context, modality) CREATE TABLE morbac.rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -25,19 +18,20 @@ CREATE TABLE morbac.rules ( CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from) ); - --- Indexes for fast lookup and validity 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); - --- Fast lookup index for active rules CREATE INDEX idx_rules_fast_lookup ON morbac.rules(org_id, activity, modality, view) INCLUDE (role_id, context_id) WHERE is_active = true; --- Placeholder trigger to maintain is_active (update as needed) +COMMENT ON TABLE morbac.rules IS 'Core OrBAC rules - Permission, Prohibition, Obligation, Recommendation'; +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 @@ -54,18 +48,8 @@ CREATE TRIGGER trg_rules_set_is_active BEFORE INSERT OR UPDATE ON morbac.rules FOR EACH ROW EXECUTE FUNCTION morbac.rules_set_is_active(); -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'; -COMMENT ON COLUMN morbac.rules.priority IS 'Optional: Rule priority (higher wins). NULL = 0. A permission with higher priority than a prohibition overrides it.'; -COMMENT ON COLUMN morbac.rules.valid_from IS 'Optional: Rule valid from this timestamp'; -COMMENT ON COLUMN morbac.rules.valid_until IS 'Optional: Rule valid until this timestamp'; +-- Check if a rule is currently valid based on temporal constraints --- Helper function to check if a rule is currently valid CREATE OR REPLACE FUNCTION morbac.is_rule_valid( p_valid_from TIMESTAMPTZ, p_valid_until TIMESTAMPTZ @@ -77,17 +61,10 @@ AS $$ DECLARE v_now TIMESTAMPTZ := CURRENT_TIMESTAMP; BEGIN - -- If no temporal constraints, rule is valid - IF p_valid_from IS NULL AND p_valid_until IS NULL THEN - RETURN TRUE; - END IF; - - -- Check valid_from IF p_valid_from IS NOT NULL AND v_now < p_valid_from THEN RETURN FALSE; END IF; - -- Check valid_until IF p_valid_until IS NOT NULL AND v_now >= p_valid_until THEN RETURN FALSE; END IF; diff --git a/src/types.sql b/src/types.sql index 5dac9bd..3cea5e7 100644 --- a/src/types.sql +++ b/src/types.sql @@ -1,8 +1,3 @@ --- ============================================================================= --- DEONTIC MODALITY TYPE --- ============================================================================= --- Represents the four deontic modalities of OrBAC model - CREATE TYPE morbac.modality AS ENUM ( 'permission', 'prohibition', diff --git a/src/validation.sql b/src/validation.sql index ca26298..9eaca7e 100644 --- a/src/validation.sql +++ b/src/validation.sql @@ -1,6 +1,4 @@ --- ============================================================================= --- VALIDATION FUNCTIONS --- ============================================================================= +-- Validation functions for SoD, cardinality, and rule conflict detection. -- -- Rule conflict detection (from Multi-OrBAC paper): -- Conflicts exist when two rules share the same (org, role, activity, view, context) @@ -12,7 +10,6 @@ -- -- Note: hierarchy-based overlaps are intentional design, not flagged here. --- 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, @@ -25,7 +22,6 @@ AS $$ DECLARE v_conflict_exists BOOLEAN; BEGIN - -- Check if user already has a conflicting role SELECT EXISTS ( SELECT 1 FROM morbac.user_roles ur @@ -45,7 +41,6 @@ $$; 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 @@ -59,7 +54,6 @@ DECLARE v_min_users INTEGER; v_max_users INTEGER; BEGIN - -- Get current user count and constraints SELECT COUNT(DISTINCT ur.user_id), rc.min_users, @@ -70,28 +64,25 @@ BEGIN 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 + RETURN NULL; END; $$; COMMENT ON FUNCTION morbac.check_cardinality_violation(UUID, BOOLEAN) IS 'Returns error message if cardinality constraint would be violated, NULL otherwise'; --- Detect direct modality conflicts for a candidate rule CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts( p_org_id UUID, p_role_id UUID, @@ -99,7 +90,7 @@ CREATE OR REPLACE FUNCTION morbac.detect_rule_conflicts( p_view TEXT, p_context_id UUID, p_modality morbac.modality, - p_exclude_id UUID DEFAULT NULL -- exclude self on UPDATE + p_exclude_id UUID DEFAULT NULL ) RETURNS TABLE( conflicting_rule_id UUID, @@ -145,6 +136,7 @@ COMMENT ON FUNCTION morbac.detect_rule_conflicts(UUID, UUID, TEXT, TEXT, UUID, m 'Returns rules that directly conflict with the given tuple due to modality precedence (prohibition > obligation > recommendation > permission).'; -- Trigger: warn (non-blocking) when a new/updated rule conflicts with an existing one + CREATE OR REPLACE FUNCTION morbac.trg_warn_rule_conflicts() RETURNS TRIGGER LANGUAGE plpgsql diff --git a/src/views.sql b/src/views.sql index 26c13fc..92b3516 100644 --- a/src/views.sql +++ b/src/views.sql @@ -1,8 +1,4 @@ --- ============================================================================= --- VIEWS --- ============================================================================= --- Views represent abstract object categories in OrBAC --- These are global abstractions (not org-scoped) +-- Views are global abstract object categories (not org-scoped) CREATE TABLE morbac.views ( name TEXT PRIMARY KEY, @@ -11,14 +7,8 @@ CREATE TABLE morbac.views ( ); 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)'; - --- ============================================================================= --- VIEW HIERARCHY --- ============================================================================= --- Views can inherit from other views --- e.g., "confidential_documents" is a "documents", "admin_reports" is a "reports" +-- Senior views inherit from junior views (e.g., confidential_documents is a documents) 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,