Trusty: provenance library workspace (initial import)
This commit is contained in:
508
crates/adapters/trusty-policy-postgres/src/permits.rs
Normal file
508
crates/adapters/trusty-policy-postgres/src/permits.rs
Normal file
@@ -0,0 +1,508 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user