509 lines
18 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|