Files
Trusty/crates/adapters/trusty-policy-postgres/src/permits.rs

509 lines
18 KiB
Rust

//! 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"
);
}
}