//! The decision function every row-level-security policy calls. //! //! This is the compilation target. A `CREATE POLICY` written once per //! (table, verb) delegates to [`DDL_PERMITS`], and grants are rows, so //! issuing or revoking a permission never touches DDL and no SQL is ever //! built from a grant's contents. //! //! The evaluation order is the permission model's, and the order is //! load-bearing rather than incidental: //! //! 1. **Exclusion wins**, checked first, across *all* of the subject's //! live grants regardless of tier. An exclusion can never be //! overridden by a wider grant -- so it cannot be evaluated after //! coverage, or a second grant would reopen what the first shut. //! 2. **Expiry** is wall clock, independent of whether work finished. //! 3. **Coverage** -- some live grant's scope contains the resource. //! 4. **Tier** matches exactly. //! 5. **Delegation intersects**: an agent is never a principal in its //! own right, so the principal it acts for must independently permit //! the same thing. //! 6. Anything unmatched, NULL or unreadable is **false**. Absent policy //! is not permission. /// Scope containment. /// /// `starts_with`, never `LIKE`: a scope containing `%` or `_` would /// otherwise widen itself, and the scope string is attacker-influenced /// in exactly the deployments this is for. pub const DDL_COVERS: &str = r#" CREATE OR REPLACE FUNCTION trusty_policy.covers(scope TEXT, resource TEXT) RETURNS BOOLEAN LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $fn$ SELECT scope IS NOT NULL AND resource IS NOT NULL AND (resource = scope OR starts_with(resource, scope || '/')) $fn$ "#; /// How many `acts_for` hops are followed before the answer is refusal. /// /// A delegation cycle -- A acts for B, B acts for A -- would otherwise /// recurse until the stack gave out, and a stack overflow inside a /// policy predicate is an availability bug in the authorisation path. /// Refusing past the bound keeps the failure mode deny-by-default. pub const MAX_DELEGATION_DEPTH: i32 = 4; /// The decision function. `SECURITY DEFINER` with a pinned /// `search_path`, so the application role can consult grants without /// being able to read or write them directly. pub const DDL_PERMITS: &str = r#" CREATE OR REPLACE FUNCTION trusty_policy.permits( subject TEXT, tier TEXT, resource TEXT, depth INT DEFAULT 0 ) RETURNS BOOLEAN LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = trusty_policy, pg_temp AS $fn$ DECLARE g RECORD; BEGIN -- Deny by default. An unreadable or missing subject refuses; it -- does not fall back to something wider. IF subject IS NULL OR subject = '' OR tier IS NULL OR resource IS NULL THEN RETURN FALSE; END IF; -- A delegation cycle is refused, not followed. IF depth > 4 THEN RETURN FALSE; END IF; -- 0. Identity before permission. A grant held by a principal who -- was never registered is a grant to nobody, and one held by a -- revoked principal must stop at once rather than grant by grant -- -- revoking them one at a time is a race with whoever is using -- them. This is what makes "kill the agent's key, the person is -- untouched" true in practice. IF NOT EXISTS ( SELECT 1 FROM trusty_policy.principal p WHERE p.id = permits.subject AND p.revoked_at IS NULL ) THEN RETURN FALSE; END IF; -- 1. Exclusion wins, and is checked before coverage so that a -- second, wider grant cannot reopen what an exclusion shut. -- Deliberately NOT filtered by tier: an exclusion is a statement -- about a resource, not about a verb. IF EXISTS ( SELECT 1 FROM trusty_policy.permission_grant pgr, LATERAL unnest(pgr.excludes) AS ex(ref) WHERE pgr.subject = permits.subject AND pgr.revoked_at IS NULL AND trusty_policy.covers(ex.ref, permits.resource) ) THEN RETURN FALSE; END IF; -- 2, 3, 4. A live grant, unexpired by the wall clock, of exactly -- this tier, whose scope covers the resource. FOR g IN SELECT pgr.acts_for FROM trusty_policy.permission_grant pgr WHERE pgr.subject = permits.subject AND pgr.tier = permits.tier AND pgr.revoked_at IS NULL AND pgr.expires_at > now() AND trusty_policy.covers(pgr.scope, permits.resource) LOOP -- 5. effective = grant(agent) INTERSECT permission(acts_for). -- A grant naming no principal stands on its own. IF g.acts_for IS NULL THEN RETURN TRUE; END IF; IF EXISTS ( SELECT 1 FROM trusty_policy.delegation d WHERE d.agent_id = permits.subject AND d.acts_for_id = g.acts_for AND d.revoked_at IS NULL AND (d.expires_at IS NULL OR d.expires_at > now()) ) AND trusty_policy.permits( g.acts_for, permits.tier, permits.resource, depth + 1 ) THEN RETURN TRUE; END IF; END LOOP; -- 6. No matching grant is a refusal. RETURN FALSE; END; $fn$ "#; /// Applied after [`crate::schema::DDL_V1`], in the same transaction. pub const DDL_FUNCTIONS: &[&str] = &[ DDL_COVERS, DDL_PERMITS, DDL_ENSURE_PRINCIPAL, DDL_REINSTATE_PRINCIPAL, ]; #[cfg(test)] mod tests { use super::*; #[test] fn the_decision_function_pins_its_search_path() { assert!( DDL_PERMITS.contains("SET search_path = trusty_policy, pg_temp"), "a SECURITY DEFINER function without a pinned search_path is a \ privilege escalation: anyone able to set search_path could shadow \ permission_grant with a table of their own" ); } #[test] fn security_definer_never_appears_without_a_pinned_search_path() { for ddl in DDL_FUNCTIONS { if ddl.contains("SECURITY DEFINER") { assert!( ddl.contains("SET search_path ="), "every SECURITY DEFINER function must pin search_path, not \ just the one that happens to be checked above: {ddl}" ); } } } #[test] fn scope_containment_never_uses_like() { for ddl in DDL_FUNCTIONS { assert!( !ddl.to_uppercase().contains(" LIKE "), "LIKE would let a scope containing % or _ widen itself, and the \ scope string is attacker-influenced: {ddl}" ); } assert!( DDL_COVERS.contains("starts_with(resource, scope || '/')"), "containment must be a literal prefix test on a path separator" ); } #[test] fn a_prefix_that_is_not_a_path_segment_does_not_match() { // covers('a/b', 'a/bc') must be false: 'a/bc' is not inside // 'a/b'. The separator in the concatenation is what enforces it. assert!( DDL_COVERS.contains("scope || '/'"), "without the separator, scope 'a/b' would cover 'a/bcd', silently \ granting a sibling resource whose name merely starts the same way" ); } #[test] fn exclusion_is_evaluated_before_coverage() { let exclusion = DDL_PERMITS .find("unnest(pgr.excludes)") .expect("the exclusion check must exist"); let coverage = DDL_PERMITS .find("FOR g IN") .expect("the coverage loop must exist"); assert!( exclusion < coverage, "an exclusion checked after coverage could be overridden by a wider \ grant, which is exactly what the model forbids" ); } #[test] fn exclusions_are_not_filtered_by_tier() { let start = DDL_PERMITS .find("unnest(pgr.excludes)") .expect("the exclusion check must exist"); let end = DDL_PERMITS[start..] .find("RETURN FALSE") .expect("the exclusion check must refuse") + start; assert!( !DDL_PERMITS[start..end].contains("pgr.tier"), "an exclusion is a statement about a resource, not about a verb; \ filtering it by tier would let a read grant expose what a write \ grant excluded" ); } #[test] fn an_unregistered_or_revoked_principal_refuses() { assert!( DDL_PERMITS.contains("FROM trusty_policy.principal p"), "a grant whose subject was never registered is a grant to nobody, and deny-by-default says that refuses" ); let start = DDL_PERMITS .find("FROM trusty_policy.principal p") .expect("the principal check must exist"); assert!( DDL_PERMITS[start..].starts_with( "FROM trusty_policy.principal p WHERE p.id = permits.subject AND p.revoked_at IS NULL" ), "revoking a principal must stop every grant it holds at once, not leave them to be revoked one at a time" ); } #[test] fn identity_is_checked_before_any_grant_is_read() { let identity = DDL_PERMITS .find("FROM trusty_policy.principal p") .expect("the principal check must exist"); let exclusion = DDL_PERMITS .find("unnest(pgr.excludes)") .expect("the exclusion check must exist"); assert!( identity < exclusion, "asking who someone is before asking what they may do is both cheaper and the right order to reason about; a revoked principal should never reach the grant table at all" ); } #[test] fn a_delegation_cycle_is_refused_rather_than_followed() { assert!( DDL_PERMITS.contains("depth > 4"), "A acts for B and B acts for A would recurse until the stack gave \ out, and a stack overflow inside a policy predicate is an \ availability bug in the authorisation path" ); assert_eq!( MAX_DELEGATION_DEPTH, 4, "the documented bound and the bound in the SQL must agree" ); } #[test] fn the_delegated_side_is_checked_independently() { assert!( DDL_PERMITS.contains("trusty_policy.permits(\n g.acts_for"), "the principal must be asked the same question, not merely looked \ up: an agent is never a principal in its own right" ); } #[test] fn a_revoked_delegation_stops_permitting() { let start = DDL_PERMITS .find("FROM trusty_policy.delegation d") .expect("the delegation lookup must exist"); let window = &DDL_PERMITS[start..]; assert!( window.contains("d.revoked_at IS NULL"), "revocation must take effect at the decision, not merely be recorded" ); assert!( window.contains("d.expires_at IS NULL OR d.expires_at > now()"), "an expired delegation must stop permitting; NULL means no expiry, \ which is not the same as expired" ); } #[test] fn expiry_is_wall_clock_and_revocation_is_immediate() { assert!( DDL_PERMITS.contains("pgr.expires_at > now()"), "expiry is wall clock, independent of whether work finished" ); assert!( DDL_PERMITS.contains("pgr.revoked_at IS NULL"), "a revoked grant must stop permitting without any DDL running" ); } #[test] fn a_null_or_empty_subject_refuses() { assert!( DDL_PERMITS.contains("subject IS NULL OR subject = ''"), "current_setting('trusty.subject', true) returns NULL when unset \ and '' in some paths; either must refuse, because absent policy \ is not permission" ); } #[test] fn the_function_is_stable_not_volatile() { assert!( DDL_PERMITS.contains("STABLE"), "a VOLATILE function cannot be used in an RLS predicate efficiently \ and would be re-evaluated per row without caching" ); } #[test] fn nothing_here_is_created_outside_the_trusty_policy_schema() { for ddl in DDL_FUNCTIONS { assert!( ddl.contains("FUNCTION trusty_policy."), "an unqualified function would land in whatever search_path \ happened to be set: {ddl}" ); } } } /// Create a principal if missing, and refresh its display name. /// /// The portable half of enrolment. A consumer whose users arrive through /// its own auth table hangs a thin trigger on that table and calls this; /// the trigger is the consumer's, because only they know which table /// their users live in, but the logic is here so both sides cannot /// drift. /// /// `SECURITY DEFINER` with a pinned `search_path`, for the same reason /// [`DDL_PERMITS`] is: without the pin, anyone able to set `search_path` /// could shadow `principal` with a table of their own. /// /// **It does not un-revoke.** An earlier version did, on the reasoning /// that re-enrolment is the same decision said again. That reasoning /// holds for a person deliberately re-enrolling and fails for an /// automated repair job -- and the function cannot tell them apart. /// Reconciliation calls this same verb, so un-revoking here meant a /// revoked principal whose auth row still existed would be resurrected /// by the next backfill, silently undoing a deliberate act. Bringing /// someone back is [`DDL_REINSTATE_PRINCIPAL`], which a trigger has no /// reason to reach. pub const DDL_ENSURE_PRINCIPAL: &str = r#" CREATE OR REPLACE FUNCTION trusty_policy.ensure_principal( p_id TEXT, p_kind TEXT, p_display TEXT DEFAULT '' ) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER SET search_path = trusty_policy, pg_temp AS $fn$ BEGIN INSERT INTO trusty_policy.principal (id, kind, display) VALUES (p_id, p_kind, COALESCE(p_display, '')) ON CONFLICT (id) DO UPDATE SET display = EXCLUDED.display; END; $fn$ "#; /// Bring a revoked principal back, deliberately. /// /// Separate from [`DDL_ENSURE_PRINCIPAL`] because "create if missing" /// and "bring someone back" are different intentions, and only one of /// them should be reachable from a trigger. Revocation is an act someone /// may have to justify; undoing it should be equally deliberate, and /// equally visible in whatever called it. /// /// Returns whether a revoked principal was actually reinstated, so a /// caller reinstating someone already live is told rather than /// reassured. pub const DDL_REINSTATE_PRINCIPAL: &str = r#" CREATE OR REPLACE FUNCTION trusty_policy.reinstate_principal(p_id TEXT) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER SET search_path = trusty_policy, pg_temp AS $fn$ DECLARE changed INT; BEGIN UPDATE trusty_policy.principal SET revoked_at = NULL WHERE id = p_id AND revoked_at IS NOT NULL; GET DIAGNOSTICS changed = ROW_COUNT; RETURN changed = 1; END; $fn$ "#; /// Principals that should exist and do not, or exist and are revoked. /// /// **Must always return zero rows.** `permits()` refuses for a subject /// that is not a registered live principal, so a missed enrolment is not /// degraded access -- it is a total lockout, with nothing in any log /// saying why. This is the query that catches it before a person does. /// /// Takes a table and column to reconcile against, so a consumer can /// point it at whatever holds its users. Returns the ids that are /// missing and the ones that are revoked, separately: they need /// different fixes. pub const SQL_RECONCILE_PRINCIPALS: &str = r#" SELECT src.id, CASE WHEN p.id IS NULL THEN 'missing' ELSE 'revoked' END AS problem FROM (SELECT $1::text[] AS ids) AS _ CROSS JOIN LATERAL unnest(_.ids) AS src(id) LEFT JOIN trusty_policy.principal p ON p.id = src.id AND p.revoked_at IS NULL WHERE p.id IS NULL "#; #[cfg(test)] mod enrolment_tests { use super::*; #[test] fn ensure_principal_pins_its_search_path() { assert!( DDL_ENSURE_PRINCIPAL.contains("SET search_path = trusty_policy, pg_temp"), "a SECURITY DEFINER function without a pinned search_path lets \ anyone who can set search_path shadow `principal` with a table of \ their own" ); } #[test] fn enrolment_cannot_resurrect_a_revoked_principal() { assert!( !DDL_ENSURE_PRINCIPAL.contains("revoked_at"), "reconciliation calls this same verb, so un-revoking here would let a backfill resurrect a principal somebody deliberately revoked -- and permits() refuses a revoked principal, so it would restore everything at once" ); } #[test] fn bringing_someone_back_is_a_separate_verb() { assert!( DDL_REINSTATE_PRINCIPAL.contains("revoked_at = NULL"), "there must still be a way back, or revocation is a trapdoor" ); assert!( DDL_REINSTATE_PRINCIPAL.contains("revoked_at IS NOT NULL"), "and it must report whether it actually changed anything, so a caller reinstating someone already live is told rather than reassured" ); assert!( DDL_REINSTATE_PRINCIPAL.contains("SET search_path = trusty_policy, pg_temp"), "a SECURITY DEFINER function without a pinned search_path is a privilege escalation, and this one can restore authority" ); } #[test] fn enrolment_is_idempotent() { assert!( DDL_ENSURE_PRINCIPAL.contains("ON CONFLICT (id) DO UPDATE"), "a trigger fires more than once in practice; a second call must \ not fail and must not duplicate" ); } #[test] fn reconciliation_separates_missing_from_revoked() { assert!( SQL_RECONCILE_PRINCIPALS.contains("'missing'") && SQL_RECONCILE_PRINCIPALS.contains("'revoked'"), "the two need different fixes -- one is an enrolment that never \ ran, the other a deliberate act somebody may need to justify -- \ so a query reporting them alike hides the difference" ); assert!( SQL_RECONCILE_PRINCIPALS.contains("revoked_at IS NULL"), "a revoked principal is not a present one as far as permits() is \ concerned, so reconciliation must count it as a problem" ); } }