Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "trusty-policy-postgres"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Compiles a trusty-policy Grant into a Postgres row-level-security decision. Grants are rows and the policy is static, so no SQL is ever built from a grant's contents."
[dependencies]
trusty-policy = { workspace = true }
postgres = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
[features]
default = []

View File

@@ -0,0 +1,78 @@
//! Print the permission model's schema as a plain `.sql` migration.
//!
//! A consumer whose migrations are files rather than a Rust `open()`
//! call — Supabase, sqlx, plain psql — needs the same DDL as text.
//! Emitting it from the same constants the library applies is the point:
//! two hand-maintained copies of an authorisation schema will diverge,
//! and the divergence will be discovered by something going wrong.
//!
//! ```text
//! cargo run -p trusty-policy-postgres --example emit_ddl > 0001_permission_model.sql
//! ```
//!
//! Pass a table and its scope column to additionally emit the policies
//! that put that table under enforcement:
//!
//! ```text
//! cargo run -p trusty-policy-postgres --example emit_ddl -- public.calendar resource_scope
//! ```
use trusty_policy_postgres::permits::DDL_FUNCTIONS;
use trusty_policy_postgres::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
use trusty_policy_postgres::store::policy_statements;
fn main() {
println!("-- Generated by `cargo run -p trusty-policy-postgres --example emit_ddl`.");
println!("-- Source of truth is trusty-policy-postgres; edit there, regenerate here.");
println!("-- Applying this by hand and by GrantStore::open must agree, so do not");
println!("-- edit this file directly.");
println!();
for stmt in DDL_V1 {
println!("{};", stmt.trim());
println!();
}
for stmt in DDL_FUNCTIONS {
println!("{};", stmt.trim());
println!();
}
// The library upserts this with a bind parameter; a file has no
// parameters, so the version is inlined. It is a constant, not
// input.
println!(
"{};",
DDL_SCHEMA_VERSION_UPSERT
.trim()
.replace("$1", &format!("'{SCHEMA_VERSION}'"))
);
println!();
let args: Vec<String> = std::env::args().skip(1).collect();
match args.as_slice() {
[] => {
println!("-- No table given, so no policies were emitted.");
println!("-- Installing this schema protects nothing on its own: a table is");
println!("-- guarded only once it has ENABLE + FORCE ROW LEVEL SECURITY and the");
println!("-- four policies. Re-run with a table and its scope column, e.g.");
println!("-- ... --example emit_ddl -- public.calendar resource_scope");
}
[table, column] => match policy_statements(table, column) {
Ok(stmts) => {
println!("-- Enforcement for {table}({column}).");
for stmt in stmts {
println!("{stmt}");
}
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
},
_ => {
eprintln!("usage: emit_ddl [<schema.table> <scope_column>]");
std::process::exit(2);
}
}
}

View File

@@ -0,0 +1,282 @@
//! Compiles a [`trusty_policy::Grant`] into a decision Postgres enforces.
//!
//! # The problem this exists to solve
//!
//! `trusty-policy` decides in Rust. Postgres decides in SQL. When those
//! two answers disagree, the database wins and nobody is told. The
//! permission model closes that by making one definition serve both:
//! a grant *compiles* to a row-level-security policy. [`Tier`] was
//! defined as the database's four verbs for exactly this reason, so the
//! compilation needs no translation layer -- and a translation layer is
//! where the two answers drift apart.
//!
//! # Grants are rows; the policy is static
//!
//! One `CREATE POLICY` per (table, verb), written once:
//!
//! ```sql
//! CREATE POLICY calendar_read ON calendar FOR SELECT
//! USING (trusty_policy.permits(
//! current_setting('trusty.subject', true), 'read', resource_scope));
//! ```
//!
//! Issuing a grant is then a parameterized `INSERT`, and revoking one is
//! an `UPDATE`. **No SQL is ever built from a grant's contents**, so a
//! scope string can never become syntax. The alternative -- a
//! `CREATE POLICY` per grant -- would require exactly that, and would
//! introduce the first dynamic SQL into a workspace where every
//! statement today is a static literal with `$1` placeholders.
//!
//! # What compiles, and what does not
//!
//! `scope`, `tier`, `excludes`, `expires_at` and `acts_for` reach the
//! database. `limits` -- rate, irreversible budget, dead man -- has no
//! RLS analogue and stays in [`trusty_policy::PolicyEngine`]. That gap
//! is reported, never silent: see [`CompilationReport`]. A compiler that
//! quietly drops a rate limit is worse than one that refuses.
//!
//! # Why a separate crate
//!
//! `trusty-policy` deliberately has no substrate. `CREATE POLICY` and
//! `current_setting` are Postgres dialect, so putting them in the engine
//! would give a substrate-free crate a substrate. Same split, and the
//! same reason, as `trusty-policy-audit`.
#![deny(clippy::all)]
pub mod permits;
pub mod schema;
pub mod store;
use sha2::{Digest, Sha256};
use trusty_policy::{Grant, Tier};
pub use permits::{
DDL_ENSURE_PRINCIPAL, DDL_FUNCTIONS, DDL_REINSTATE_PRINCIPAL, DDL_PERMITS, MAX_DELEGATION_DEPTH,
SQL_RECONCILE_PRINCIPALS,
};
pub use schema::{DDL_V1, SCHEMA_NAME, SCHEMA_VERSION};
pub use store::{policy_statements, GrantStore, PolicySession, SUBJECT_SETTING};
/// A grant's identity: the SHA-256 of its RFC 8785 canonical JSON, hex
/// encoded.
///
/// Content-addressed on purpose. Issuing the same grant twice is
/// idempotent rather than duplicative, and the id is precisely the bytes
/// a signature over the grant would cover -- so the row, the id and the
/// signed artifact can never disagree about what was granted.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GrantId(String);
impl GrantId {
/// Derive the id from a grant's canonical form.
pub fn of(grant: &Grant) -> Result<Self, PolicyPgError> {
let canonical = grant
.canonical_json()
.map_err(|e| PolicyPgError::Canonicalise(e.to_string()))?;
Ok(Self::of_canonical(&canonical))
}
/// Derive the id from canonical JSON that is already in hand.
pub fn of_canonical(canonical: &str) -> Self {
let digest = Sha256::digest(canonical.as_bytes());
Self(hex::encode(digest))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for GrantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// What a grant carried that the database will not be enforcing.
///
/// Returned alongside every issued grant so a caller cannot mistake
/// partial compilation for total. Every field named here is still
/// enforced -- by [`trusty_policy::PolicyEngine`], in the application --
/// but *not* by Postgres, and a caller relying on the database alone
/// would be relying on something that is not there.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CompilationReport {
/// Names of grant fields that did not reach the database.
pub uncompiled: Vec<&'static str>,
}
impl CompilationReport {
/// Build the report for a grant.
pub fn of(grant: &Grant) -> Self {
let mut uncompiled = Vec::new();
if grant.limits.rate_per_min.is_some() {
uncompiled.push("limits.rate_per_min");
}
if grant.limits.irreversible_budget.is_some() {
uncompiled.push("limits.irreversible_budget");
}
if grant.limits.dead_man_secs.is_some() {
uncompiled.push("limits.dead_man_secs");
}
Self { uncompiled }
}
/// True when every field of the grant reached the database, so the
/// database alone is a sufficient enforcer of it.
pub fn is_total(&self) -> bool {
self.uncompiled.is_empty()
}
}
/// The lowercase token a [`Tier`] is stored and compared as.
///
/// Distinct from [`Tier::sql_verb`], which gives the SQL keyword. The
/// two are 1:1 -- that is the whole design -- but they are not the same
/// string, and conflating them would put `SELECT` in a data column.
pub fn tier_token(tier: Tier) -> &'static str {
match tier {
Tier::Read => "read",
Tier::Append => "append",
Tier::Update => "update",
Tier::Delete => "delete",
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyPgError {
#[error("postgres: {0}")]
Postgres(#[from] postgres::Error),
#[error("could not canonicalise the grant: {0}")]
Canonicalise(String),
#[error(
"no relation named {0:?} exists, so enabling row-level security on it \
would silently protect nothing"
)]
UnknownRelation(String),
#[error(
"no principal named {0:?} is registered, and a key bound to nobody is a key nothing can revoke"
)]
UnknownPrincipal(String),
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_policy::Limits;
fn grant() -> Grant {
Grant {
why: Some("Sam's scheduling agent may propose, never book.".into()),
subject: "agent:01J".into(),
acts_for: Some("user:01H".into()),
scope: "board:9f2/calendar".into(),
tier: Tier::Append,
excludes: vec!["board:9f2/calendar/private".into()],
limits: Limits::default(),
expires_at: 1_788_000_000,
}
}
#[test]
fn the_id_is_the_hash_of_the_canonical_form() {
let g = grant();
let canonical = g.canonical_json().expect("canonical form");
assert_eq!(
GrantId::of(&g).expect("id"),
GrantId::of_canonical(&canonical),
"the id must be derivable from the canonical bytes alone, or a \
verifier holding only the signed artifact could not name the row"
);
}
#[test]
fn issuing_the_same_grant_twice_yields_one_id() {
assert_eq!(
GrantId::of(&grant()).expect("id"),
GrantId::of(&grant()).expect("id"),
"a content-addressed id makes re-issuing idempotent rather than \
quietly accumulating duplicate permissions"
);
}
#[test]
fn changing_why_changes_the_id() {
let mut other = grant();
other.why = Some("a different reason entirely".into());
assert_ne!(
GrantId::of(&grant()).expect("id"),
GrantId::of(&other).expect("id"),
"the justification is inside the hash, not beside it: a grant \
whose stated reason can be edited without changing its identity \
is missing the half an auditor asks about"
);
}
#[test]
fn a_grant_without_limits_compiles_totally() {
let report = CompilationReport::of(&grant());
assert!(
report.is_total(),
"with no limits set there is nothing the database cannot express, \
so it alone is a sufficient enforcer; got {report:?}"
);
}
#[test]
fn a_grant_with_limits_reports_them_as_uncompiled() {
let mut g = grant();
g.limits = Limits {
rate_per_min: Some(20),
irreversible_budget: Some(0),
dead_man_secs: Some(1800),
};
let report = CompilationReport::of(&g);
assert!(
!report.is_total(),
"a rate limit that reached no enforcer must never look enforced"
);
assert_eq!(
report.uncompiled,
vec![
"limits.rate_per_min",
"limits.irreversible_budget",
"limits.dead_man_secs"
],
"each unenforceable field must be named, so the caller knows which \
enforcement it still owes the gate"
);
}
#[test]
fn the_stored_token_is_not_the_sql_verb() {
assert_eq!(tier_token(Tier::Read), "read");
assert_eq!(Tier::Read.sql_verb(), "SELECT");
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
assert_ne!(
tier_token(tier),
tier.sql_verb(),
"the data token and the SQL keyword are 1:1 but not identical; \
conflating them would put a SQL keyword in a data column"
);
}
}
#[test]
fn every_tier_has_a_distinct_token() {
let tokens: Vec<_> = [Tier::Read, Tier::Append, Tier::Update, Tier::Delete]
.into_iter()
.map(tier_token)
.collect();
let mut sorted = tokens.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
tokens.len(),
"two tiers sharing a token would let a grant for one silently \
authorise the other"
);
}
}

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

View File

@@ -0,0 +1,312 @@
//! Schema DDL for the grant store.
//!
//! Follows `trusty-postgres`'s idiom exactly: a `&[&str]` of
//! `CREATE ... IF NOT EXISTS` applied in one transaction on every open,
//! followed by a `schema_meta` version upsert. There is no migration
//! runner here and there should not be one -- the consumer's migration
//! tool owns *its* tables; this owns only the permission model's own.
//!
//! Everything lives in its own `trusty_policy` schema so it can never
//! collide with an application table, and so revoking write access to
//! the whole schema is one statement.
pub const SCHEMA_VERSION: &str = "1";
/// The Postgres schema every object below lives in.
pub const SCHEMA_NAME: &str = "trusty_policy";
pub const DDL_V1: &[&str] = &[
r#"CREATE SCHEMA IF NOT EXISTS trusty_policy"#,
// ----------------------------------------------------------------
// principal -- one table, four kinds.
//
// `id` is TEXT holding the full reference ("agent:01J..."), not a
// bare UUID. The permission model's canonical grant JSON carries
// subjects in that prefixed form, and `Grant.subject` is a String;
// storing a bare uuid would mean parsing the prefix off on every
// write and re-attaching it on every read, which is exactly the
// translation layer this design exists to avoid.
//
// The CHECK keeps the prefix honest, so `kind` cannot drift from
// what the id claims.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.principal (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('org','user','agent','program')),
display TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The `key` edge. Points at trusty's producer registry
-- (producer_identity.producer_oid), which is the agent identity
-- system; there is not to be a second one. Deliberately no
-- foreign key: that table belongs to trusty-postgres, lives in
-- another schema, and may not be installed at all. The edge is
-- recorded here and resolved through lookup_producer.
producer_oid TEXT UNIQUE,
-- Revoking the identity itself, as distinct from revoking any
-- one grant. Kills every grant the principal holds at once,
-- which is what "kill the agent's key, the person is untouched"
-- has to mean in practice.
revoked_at TIMESTAMPTZ,
-- What KIND of thing this is, structurally, is `kind`. What kind
-- of ORGANISATION it is -- nonprofit, agency, prime, sub -- is
-- descriptive and belongs here, not there.
--
-- Kept apart deliberately. Folded into `kind`, every new
-- organisation type becomes a change to permits(), and the
-- authorisation function ends up knowing about tax status. The
-- CHECK on `kind` is what keeps the two from being conflated by
-- accident.
org_type TEXT,
CONSTRAINT principal_id_matches_kind CHECK (starts_with(id, kind || ':')),
-- Only an org has an org type. A user with one is a modelling
-- mistake that would otherwise sit there looking deliberate.
CONSTRAINT org_type_only_on_orgs CHECK (org_type IS NULL OR kind = 'org')
)"#,
// Idempotent columns for an installation that predates them.
// CREATE TABLE IF NOT EXISTS silently does nothing when the table is
// already there, so a new column needs saying twice.
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS producer_oid TEXT"#,
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ"#,
r#"ALTER TABLE trusty_policy.principal
ADD COLUMN IF NOT EXISTS org_type TEXT"#,
r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_principal_producer_oid
ON trusty_policy.principal(producer_oid)"#,
// ----------------------------------------------------------------
// membership -- user belongs to org.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.membership (
member_id TEXT NOT NULL REFERENCES trusty_policy.principal(id) ON DELETE CASCADE,
org_id TEXT NOT NULL REFERENCES trusty_policy.principal(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, org_id)
)"#,
// ----------------------------------------------------------------
// delegation -- agent acts for user | org.
//
// Deliberately NOT cascaded on delete: revoking a delegation is
// `revoked_at`, and losing the row would lose the record that the
// delegation ever existed. An audit asks "who could act for whom
// last March", and a deleted row cannot answer.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.delegation (
agent_id TEXT NOT NULL REFERENCES trusty_policy.principal(id),
acts_for_id TEXT NOT NULL REFERENCES trusty_policy.principal(id),
granted_by TEXT REFERENCES trusty_policy.principal(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
PRIMARY KEY (agent_id, acts_for_id)
)"#,
// ----------------------------------------------------------------
// permission_grant -- the grant itself.
//
// Named `permission_grant`, not `grant`: GRANT is a reserved word in
// SQL, and a table that must be double-quoted at every call site is
// a footgun waiting for the one place someone forgets.
//
// `id` is the SHA-256 of `canonical_json`, so a grant's identity IS
// its content. Issuing the same grant twice is idempotent, and the
// id is exactly what a signature over the grant would cover.
//
// `canonical_json` is stored verbatim -- RFC 8785 bytes, not a JSONB
// column. JSONB renormalises whitespace and key order, which would
// destroy the very property the canonical form exists to provide.
// Same reasoning that keeps `raw_payload` TEXT in trusty-postgres.
// ----------------------------------------------------------------
r#"CREATE TABLE IF NOT EXISTS trusty_policy.permission_grant (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
acts_for TEXT,
scope TEXT NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('read','append','update','delete')),
excludes TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
why TEXT,
canonical_json TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
)"#,
// Every permits() call filters on subject first, then tier.
r#"CREATE INDEX IF NOT EXISTS idx_grant_subject_tier
ON trusty_policy.permission_grant(subject, tier)"#,
// Exclusions are checked across ALL of a subject's grants, including
// ones whose tier does not match, so they need their own path in.
r#"CREATE INDEX IF NOT EXISTS idx_grant_subject
ON trusty_policy.permission_grant(subject)"#,
r#"CREATE TABLE IF NOT EXISTS trusty_policy.schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"#,
];
/// Upserts the schema_version row. Runs on every open to declare the
/// version this build expects. Mirrors `trusty-postgres`.
pub const DDL_SCHEMA_VERSION_UPSERT: &str = r#"
INSERT INTO trusty_policy.schema_meta(key, value) VALUES ('schema_version', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_statement_is_idempotent() {
for stmt in DDL_V1 {
let upper = stmt.to_uppercase();
assert!(
upper.contains("IF NOT EXISTS"),
"the schema is applied on every open, so a statement without \
IF NOT EXISTS would fail the second time a process starts: {stmt}"
);
}
}
#[test]
fn nothing_is_created_outside_the_trusty_policy_schema() {
for stmt in DDL_V1 {
if stmt.to_uppercase().starts_with("CREATE SCHEMA") {
continue;
}
assert!(
stmt.contains("trusty_policy."),
"an unqualified object would land in whatever search_path \
happened to be set, which is not a place to keep permissions: {stmt}"
);
}
}
#[test]
fn the_grant_table_avoids_the_reserved_word() {
let ddl = DDL_V1.join("\n").to_uppercase();
assert!(
!ddl.contains("EXISTS TRUSTY_POLICY.GRANT "),
"GRANT is reserved in SQL; the table is permission_grant precisely \
so no call site has to remember to double-quote it"
);
}
#[test]
fn the_canonical_form_is_stored_as_text_not_jsonb() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("canonical_json TEXT"),
"JSONB renormalises whitespace and key order, which would destroy \
the exact property RFC 8785 canonicalisation exists to give"
);
assert!(
!ddl.to_uppercase().contains("JSONB"),
"no column here may be JSONB, for the same reason"
);
}
#[test]
fn tier_is_constrained_to_the_four_verbs() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("tier IN ('read','append','update','delete')"),
"a tier outside the four is a permission nobody can enforce; \
the database should refuse to store one"
);
}
#[test]
fn a_principals_kind_cannot_drift_from_its_id() {
let ddl = DDL_V1.join("\n");
assert!(
ddl.contains("principal_id_matches_kind"),
"if kind and the id prefix can disagree, an audit line reading \
'agent:...' whose kind says 'user' is unreadable"
);
}
#[test]
fn org_type_is_descriptive_and_kind_is_structural() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("org_type"),
"an organisation's type is descriptive; folded into `kind` it would make every new organisation type a change to permits()"
);
assert!(
ddl.contains("kind IN ('org','user','agent','program')"),
"and `kind` must stay the closed structural set, or the two collapse back into one column"
);
assert!(
ddl.contains("org_type_only_on_orgs"),
"a user carrying an org type is a modelling mistake that would otherwise sit there looking deliberate"
);
}
#[test]
fn permits_never_needs_to_read_org_type() {
// Structural, not a runtime check: the decision function selects
// its own columns, and org_type is not among them. If it ever
// is, authorisation has started depending on what sort of
// organisation someone is, which is the coupling this split
// exists to prevent.
assert!(
!crate::permits::DDL_PERMITS.contains("org_type"),
"permits() must not read org_type"
);
}
#[test]
fn the_key_edge_points_at_the_producer_registry() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("producer_oid"),
"the third edge -- principal has an ed25519 key -- must be recorded, and it must point at trusty's producer registry rather than at a second identity system"
);
assert!(
!ddl.contains("ed25519_pubkey"),
"storing the key material here would BE the second identity system; only the reference to the registry belongs here"
);
}
#[test]
fn a_principal_can_be_revoked_as_a_whole() {
let ddl = DDL_V1.join("
");
assert!(
ddl.contains("revoked_at TIMESTAMPTZ"),
"killing an agent's key must stop every grant it holds at once; revoking them one at a time is a race with whoever is using them"
);
}
#[test]
fn a_column_added_after_v1_is_added_idempotently_too() {
let ddl = DDL_V1.join("
");
for col in ["producer_oid", "revoked_at"] {
assert!(
ddl.contains(&format!("ADD COLUMN IF NOT EXISTS {col}")),
"CREATE TABLE IF NOT EXISTS does nothing when the table already exists, so a column added later never reaches an existing installation unless it is also ALTERed in: {col}"
);
}
}
#[test]
fn a_delegation_is_revoked_not_deleted() {
let delegation = DDL_V1
.iter()
.find(|s| s.contains("trusty_policy.delegation"))
.expect("the delegation table must exist");
assert!(
delegation.contains("revoked_at"),
"revocation must be recorded, not effected by deletion"
);
assert!(
!delegation.contains("ON DELETE CASCADE"),
"cascading a delegation away would erase the record that it ever \
existed, and 'who could act for whom last March' is exactly what \
an audit asks"
);
}
}

View File

@@ -0,0 +1,616 @@
//! Issuing grants, and putting a table under their protection.
//!
//! Three things live here:
//!
//! - [`GrantStore`] — issue and revoke grants. Both are parameterized
//! statements against rows; neither runs DDL.
//! - [`PolicySession`] — set the subject for one transaction, so a
//! policy predicate has someone to ask about.
//! - [`GrantStore::enforce`] — the one place a table name reaches SQL,
//! and the only DDL a consumer runs after install.
use postgres::{Client, NoTls, Transaction};
use trusty_policy::{Grant, Tier};
use crate::permits::DDL_FUNCTIONS;
use crate::schema::{DDL_SCHEMA_VERSION_UPSERT, DDL_V1, SCHEMA_VERSION};
use crate::{tier_token, CompilationReport, GrantId, PolicyPgError};
/// The GUC a policy predicate reads the acting subject from.
///
/// Set transaction-locally by [`PolicySession::begin`]. Transaction
/// local, not session local, so a pooled connection handed to the next
/// request cannot still be carrying the last one's identity.
pub const SUBJECT_SETTING: &str = "trusty.subject";
/// Reads and writes grants.
pub struct GrantStore {
client: Client,
}
impl GrantStore {
/// Connect and apply the schema idempotently.
pub fn open(conn_str: &str) -> Result<Self, PolicyPgError> {
let mut client = Client::connect(conn_str, NoTls)?;
apply_schema(&mut client)?;
Ok(Self { client })
}
/// Adopt an existing connection, applying the schema to it.
pub fn adopt(mut client: Client) -> Result<Self, PolicyPgError> {
apply_schema(&mut client)?;
Ok(Self { client })
}
pub fn client(&mut self) -> &mut Client {
&mut self.client
}
/// Record a grant, and say what about it the database will not be
/// enforcing.
///
/// Idempotent: the id is the hash of the canonical form, so issuing
/// the same grant twice leaves one row rather than two permissions.
/// Re-issuing a previously revoked grant un-revokes it, which is the
/// same decision said again.
pub fn issue(&mut self, grant: &Grant) -> Result<(GrantId, CompilationReport), PolicyPgError> {
let canonical = grant
.canonical_json()
.map_err(|e| PolicyPgError::Canonicalise(e.to_string()))?;
let id = GrantId::of_canonical(&canonical);
let expires = expires_at_sql(grant.expires_at);
self.client.execute(
"INSERT INTO trusty_policy.permission_grant
(id, subject, acts_for, scope, tier, excludes,
expires_at, why, canonical_json)
VALUES ($1, $2, $3, $4, $5, $6, to_timestamp($7), $8, $9)
ON CONFLICT (id) DO UPDATE SET revoked_at = NULL",
&[
&id.as_str(),
&grant.subject,
&grant.acts_for,
&grant.scope,
&tier_token(grant.tier),
&grant.excludes,
&expires,
&grant.why,
&canonical,
],
)?;
Ok((id, CompilationReport::of(grant)))
}
/// Register a principal, or update its display name.
///
/// `kind` must be one of `org`, `user`, `agent`, `program`, and the
/// id must carry it as a prefix — `agent:01J...`. The database
/// enforces both; this returns the error rather than papering over
/// it, because a principal whose kind disagrees with its id makes
/// every audit line that mentions it unreadable.
///
/// Registering an already-revoked principal reinstates it, which is
/// the same decision said again.
pub fn register_principal(
&mut self,
id: &str,
kind: &str,
display: &str,
) -> Result<(), PolicyPgError> {
self.client.execute(
"INSERT INTO trusty_policy.principal (id, kind, display)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE
SET display = EXCLUDED.display, revoked_at = NULL",
&[&id, &kind, &display],
)?;
Ok(())
}
/// Record the `key` edge: this principal is that producer.
///
/// `producer_oid` refers to trusty's producer registry —
/// `register_producer` / `lookup_producer` / `revoke_producer` in
/// `trusty-postgres`. That registry is the agent identity system and
/// there is not to be a second one, so no key material is stored
/// here; only the reference.
///
/// There is deliberately no foreign key. `producer_identity` belongs
/// to a different crate and schema and may not be installed at all,
/// and a policy engine that refuses to start without a chain store
/// would be a policy engine with a substrate.
pub fn bind_key(&mut self, principal: &str, producer_oid: &str) -> Result<(), PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.principal SET producer_oid = $2 WHERE id = $1",
&[&principal, &producer_oid],
)?;
if n == 0 {
return Err(PolicyPgError::UnknownPrincipal(principal.to_string()));
}
Ok(())
}
/// The producer this principal is, if the edge has been recorded.
///
/// Resolving it to an actual public key goes through the producer
/// registry's own `lookup_producer`. Doing that here would mean
/// reimplementing half of it.
pub fn producer_oid_of(&mut self, principal: &str) -> Result<Option<String>, PolicyPgError> {
let row = self.client.query_opt(
"SELECT producer_oid FROM trusty_policy.principal WHERE id = $1",
&[&principal],
)?;
Ok(row.and_then(|r| r.get::<_, Option<String>>(0)))
}
/// Revoke a principal, and with it every grant the principal holds.
///
/// This is the blunt instrument, and it is meant to be. Revoking
/// grants one at a time is a race with whoever is using them; the
/// point of "kill the agent's key, the person is untouched" is that
/// one action ends the agent's authority entirely while leaving the
/// principal it acted for alone.
///
/// The grant rows survive. An audit asks what was permitted last
/// March, and a deleted row cannot answer.
///
/// Returns whether a live principal was actually revoked.
pub fn revoke_principal(&mut self, id: &str) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.principal
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL",
&[&id],
)?;
Ok(n == 1)
}
/// Record that `agent` may act for `acts_for`.
///
/// The delegation edge. Without it, an agent's grant naming a
/// principal is inert: [`GrantStore::permits`] requires both the
/// edge and the principal's own permission.
pub fn delegate(&mut self, agent: &str, acts_for: &str) -> Result<(), PolicyPgError> {
self.client.execute(
"INSERT INTO trusty_policy.delegation (agent_id, acts_for_id)
VALUES ($1, $2)
ON CONFLICT (agent_id, acts_for_id) DO UPDATE SET revoked_at = NULL",
&[&agent, &acts_for],
)?;
Ok(())
}
/// End a delegation without touching either side's grants.
pub fn revoke_delegation(&mut self, agent: &str, acts_for: &str) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.delegation SET revoked_at = now()
WHERE agent_id = $1 AND acts_for_id = $2 AND revoked_at IS NULL",
&[&agent, &acts_for],
)?;
Ok(n == 1)
}
/// Stop a grant permitting, without running any DDL.
///
/// Returns whether a live grant was actually revoked, so a caller
/// revoking something already gone is told rather than reassured.
/// The row stays: an audit asks what was permitted last March, and a
/// deleted row cannot answer.
pub fn revoke(&mut self, id: &GrantId) -> Result<bool, PolicyPgError> {
let n = self.client.execute(
"UPDATE trusty_policy.permission_grant
SET revoked_at = now()
WHERE id = $1 AND revoked_at IS NULL",
&[&id.as_str()],
)?;
Ok(n == 1)
}
/// Ask the database the same question a policy predicate asks.
///
/// For diagnostics and tests. Enforcement does not go through here —
/// it goes through the policy — but a caller debugging a refusal
/// wants to ask directly rather than infer from an empty result set.
pub fn permits(
&mut self,
subject: &str,
tier: Tier,
resource: &str,
) -> Result<bool, PolicyPgError> {
let row = self.client.query_one(
"SELECT trusty_policy.permits($1, $2, $3)",
&[&subject, &tier_token(tier), &resource],
)?;
Ok(row.get::<_, Option<bool>>(0).unwrap_or(false))
}
/// Put one table under grant enforcement.
///
/// `scope_column` names the column holding each row's resource
/// reference — the value a grant's scope is tested against.
///
/// This is the only place an identifier reaches SQL text. It is not
/// escaped by hand: the server is asked to resolve and quote it, via
/// `to_regclass` and `format('%I')`, and a name that does not
/// resolve to a real table is refused rather than quoted. Escaping
/// is a thing you can get subtly wrong; existence is not.
///
/// Returns the statements executed, so a caller can log or review
/// exactly what was applied.
pub fn enforce(
&mut self,
table: &str,
scope_column: &str,
) -> Result<Vec<String>, PolicyPgError> {
let row = self
.client
.query_opt(
"SELECT format('%I.%I', n.nspname, c.relname),
format('%I', a.attname),
c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
WHERE c.oid = to_regclass($1)
AND c.relkind = 'r'
AND a.attname = $2
AND a.attnum > 0
AND NOT a.attisdropped",
&[&table, &scope_column],
)?
.ok_or_else(|| PolicyPgError::UnknownRelation(format!("{table}.{scope_column}")))?;
let qualified: String = row.get(0);
let column: String = row.get(1);
let bare: String = row.get(2);
let mut applied = Vec::new();
// FORCE is not optional. Plain ENABLE is bypassed by the table
// owner, and the owner is who most connections are. Without
// FORCE every policy below is decorative.
for stmt in [
format!("ALTER TABLE {qualified} ENABLE ROW LEVEL SECURITY"),
format!("ALTER TABLE {qualified} FORCE ROW LEVEL SECURITY"),
] {
self.client.batch_execute(&stmt)?;
applied.push(stmt);
}
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let policy = policy_name(&bare, tier);
let quoted: String = self
.client
.query_one("SELECT format('%I', $1::text)", &[&policy])?
.get(0);
// DROP first so enforce() is idempotent: re-running it after
// a definition change must converge, not fail.
let drop = format!("DROP POLICY IF EXISTS {quoted} ON {qualified}");
self.client.batch_execute(&drop)?;
applied.push(drop);
let create = create_policy_sql(&quoted, &qualified, &column, tier);
self.client.batch_execute(&create)?;
applied.push(create);
}
Ok(applied)
}
}
/// The four policy statements that put `table` under grant enforcement,
/// plus the two `ALTER TABLE` lines that make them bite.
///
/// Pure: it touches no database and creates nothing. Use it to generate
/// a migration for a table that does not exist yet, where
/// [`GrantStore::enforce`] cannot help because it resolves the name
/// against `pg_class` first.
///
/// Because there is no catalogue to resolve against, identifiers are
/// checked by shape instead, and refused rather than escaped. The output
/// is SQL text destined for a file a person reviews; that is a good
/// reason to be strict about it, not a reason to relax.
pub fn policy_statements(
table: &str,
scope_column: &str,
) -> Result<Vec<String>, PolicyPgError> {
let qualified = check_identifier(table)?;
let column = check_identifier(scope_column)?;
let bare = table.rsplit('.').next().unwrap_or(table).to_string();
let mut out = vec![
format!("ALTER TABLE {qualified} ENABLE ROW LEVEL SECURITY;"),
format!("ALTER TABLE {qualified} FORCE ROW LEVEL SECURITY;"),
];
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let policy = policy_name(&bare, tier);
out.push(format!(
"DROP POLICY IF EXISTS {policy} ON {qualified};"
));
out.push(format!(
"{};",
create_policy_sql(&policy, &qualified, &column, tier)
));
}
Ok(out)
}
/// Accept an unquoted SQL identifier, optionally schema-qualified.
///
/// Deliberately narrow: lowercase ASCII, digits and underscore, not
/// starting with a digit, at most one dot. Anything else is refused
/// rather than quoted, because a name needing quotes in a generated
/// migration is a name worth a human deciding about.
fn check_identifier(name: &str) -> Result<String, PolicyPgError> {
let refuse = || PolicyPgError::UnknownRelation(name.to_string());
if name.is_empty() || name.len() > 128 || name.matches('.').count() > 1 {
return Err(refuse());
}
for part in name.split('.') {
let mut chars = part.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c == '_' => {}
_ => return Err(refuse()),
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
return Err(refuse());
}
}
Ok(name.to_string())
}
/// The policy statement for one (table, verb).
///
/// `INSERT` takes `WITH CHECK` and no `USING`; `DELETE` and `SELECT`
/// take `USING` and no `WITH CHECK`; `UPDATE` takes both, because a row
/// may be moved out of a permitted scope as easily as into one. Getting
/// this wrong is the classic RLS hole — a policy that guards which rows
/// you may change but not what you may change them into.
fn create_policy_sql(policy: &str, table: &str, column: &str, tier: Tier) -> String {
let predicate = format!(
"trusty_policy.permits(current_setting('{SUBJECT_SETTING}', true), '{}', {column})",
tier_token(tier)
);
let verb = tier.sql_verb();
match tier {
Tier::Append => {
format!("CREATE POLICY {policy} ON {table} FOR {verb} WITH CHECK ({predicate})")
}
Tier::Update => format!(
"CREATE POLICY {policy} ON {table} FOR {verb} \
USING ({predicate}) WITH CHECK ({predicate})"
),
Tier::Read | Tier::Delete => {
format!("CREATE POLICY {policy} ON {table} FOR {verb} USING ({predicate})")
}
}
}
/// Deterministic policy name for a (table, verb), so re-running
/// [`GrantStore::enforce`] replaces rather than accumulates.
fn policy_name(table: &str, tier: Tier) -> String {
format!("trusty_{table}_{}", tier_token(tier))
}
/// Grant expiry crosses as seconds since the epoch and is turned into a
/// `timestamptz` by `to_timestamp` server-side.
///
/// `Grant.expires_at` is an `i64` of unix seconds; the column is
/// `TIMESTAMPTZ`. Converting in Rust would mean picking a timezone, and
/// the one thing worse than a wrong expiry is a wrong expiry that looks
/// right in one region.
fn expires_at_sql(expires_at: i64) -> f64 {
expires_at as f64
}
fn apply_schema(client: &mut Client) -> Result<(), PolicyPgError> {
let mut txn = client.transaction()?;
for stmt in DDL_V1 {
txn.batch_execute(stmt)?;
}
for stmt in DDL_FUNCTIONS {
txn.batch_execute(stmt)?;
}
txn.execute(DDL_SCHEMA_VERSION_UPSERT, &[&SCHEMA_VERSION])?;
txn.commit()?;
Ok(())
}
/// A transaction carrying the identity of whoever is acting.
///
/// Every policy predicate reads `current_setting('trusty.subject')`.
/// Outside a `PolicySession` that setting is unset, the predicate sees
/// NULL, and every policy refuses — which is the correct default: code
/// that forgets to say who it is gets nothing, rather than everything.
pub struct PolicySession<'a> {
txn: Transaction<'a>,
}
impl<'a> PolicySession<'a> {
/// Begin a transaction acting as `subject`.
pub fn begin(client: &'a mut Client, subject: &str) -> Result<Self, PolicyPgError> {
let mut txn = client.transaction()?;
// `true` is is_local: the setting dies with the transaction, so
// a pooled connection cannot carry one request's identity into
// the next.
txn.execute(
"SELECT set_config($1, $2, true)",
&[&SUBJECT_SETTING, &subject],
)?;
Ok(Self { txn })
}
pub fn txn(&mut self) -> &mut Transaction<'a> {
&mut self.txn
}
pub fn commit(self) -> Result<(), PolicyPgError> {
self.txn.commit()?;
Ok(())
}
pub fn rollback(self) -> Result<(), PolicyPgError> {
self.txn.rollback()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_takes_with_check_and_no_using() {
let sql = create_policy_sql("p", "t", "scope", Tier::Append);
assert!(
sql.contains("WITH CHECK") && !sql.contains("USING"),
"an INSERT policy has no existing row to test, so USING is not just \
redundant, it is rejected by Postgres: {sql}"
);
assert!(sql.contains("FOR INSERT"), "got {sql}");
}
#[test]
fn update_takes_both_using_and_with_check() {
let sql = create_policy_sql("p", "t", "scope", Tier::Update);
assert!(
sql.contains("USING") && sql.contains("WITH CHECK"),
"USING alone guards which rows may be changed but not what they may \
be changed INTO, so a row could be moved out of a permitted scope. \
That is the classic RLS hole: {sql}"
);
}
#[test]
fn read_and_delete_take_using_and_no_with_check() {
for tier in [Tier::Read, Tier::Delete] {
let sql = create_policy_sql("p", "t", "scope", tier);
assert!(
sql.contains("USING") && !sql.contains("WITH CHECK"),
"neither reading nor deleting produces a new row to check: {sql}"
);
}
}
#[test]
fn each_policy_asks_about_its_own_tier() {
for tier in [Tier::Read, Tier::Append, Tier::Update, Tier::Delete] {
let sql = create_policy_sql("p", "t", "scope", tier);
assert!(
sql.contains(&format!("'{}'", tier_token(tier))),
"a policy for one verb asking about another would let a grant \
to read authorise a delete: {sql}"
);
assert!(
sql.contains(&format!("FOR {}", tier.sql_verb())),
"the SQL verb and the tier token must stay in step: {sql}"
);
}
}
#[test]
fn the_predicate_reads_the_subject_from_the_transaction() {
let sql = create_policy_sql("p", "t", "scope", Tier::Read);
assert!(
sql.contains("current_setting('trusty.subject', true)"),
"the second argument must be `true` (missing_ok), or an unset \
subject raises instead of refusing, and an error is not a denial: \
{sql}"
);
}
#[test]
fn an_identifier_that_would_need_quoting_is_refused() {
for bad in [
"public.\"; DROP TABLE x; --",
"public.tab le",
"Public.Table",
"9lives",
"a.b.c",
"",
"tab'le",
] {
assert!(
check_identifier(bad).is_err(),
"a name needing quotes in a generated migration is a name worth a human deciding about, not one worth escaping: {bad:?}"
);
}
assert!(check_identifier("public.calendar_event").is_ok());
assert!(check_identifier("calendar").is_ok());
}
#[test]
fn generated_statements_force_row_level_security() {
let stmts = policy_statements("public.calendar", "resource_scope").expect("emit");
assert!(
stmts.iter().any(|s| s.contains("FORCE ROW LEVEL SECURITY")),
"ENABLE alone is bypassed by the table owner, so a migration emitting only ENABLE would read as protection and provide none"
);
assert_eq!(
stmts.len(),
2 + 4 * 2,
"two ALTERs, then a DROP and a CREATE for each of the four verbs"
);
}
#[test]
fn generated_statements_match_what_enforce_would_run() {
let generated = policy_statements("public.calendar", "resource_scope").expect("emit");
// enforce() builds the same CREATE POLICY text, minus the
// trailing semicolons a file needs. If these ever diverge, a
// migration and a live call would mean different things.
let direct = create_policy_sql(
&policy_name("calendar", Tier::Read),
"public.calendar",
"resource_scope",
Tier::Read,
);
assert!(
generated.iter().any(|s| s.trim_end_matches(';') == direct),
"the emitted migration and enforce() must produce the same policy, or the file a reviewer reads is not the rule the database gets"
);
}
#[test]
fn policy_names_are_deterministic_per_table_and_verb() {
assert_eq!(policy_name("calendar", Tier::Read), "trusty_calendar_read");
assert_ne!(
policy_name("calendar", Tier::Read),
policy_name("calendar", Tier::Delete),
"one name per (table, verb) is what makes enforce() converge on \
re-run instead of accumulating policies"
);
}
#[test]
fn a_grants_contents_never_appear_in_policy_sql() {
let sql = create_policy_sql("p", "t", "scope", Tier::Read);
for grant_shaped in ["board:9f2", "agent:01J", "user:01H"] {
assert!(
!sql.contains(grant_shaped),
"the policy is written once and knows nothing about any \
particular grant; if a grant's contents reached this string, \
a scope could become syntax: {sql}"
);
}
}
#[test]
fn the_subject_setting_is_transaction_local() {
// Guards the literal in PolicySession::begin. `false` here would
// leave the identity on a pooled connection after the request
// that set it had finished.
let src = include_str!("store.rs");
assert!(
src.contains("&[&SUBJECT_SETTING, &subject],"),
"set_config must be called with the constant, not an ad-hoc string"
);
assert!(
src.contains("\"SELECT set_config($1, $2, true)\""),
"is_local must be true, or a pooled connection carries one \
request's identity into the next"
);
}
}

View File

@@ -0,0 +1,883 @@
//! Behavioural tests against a real Postgres.
//!
//! Everything in the crate's unit tests asserts properties of SQL
//! *text*. These assert what the database actually does with it, which
//! is the only claim worth making about an authorisation boundary.
//!
//! Skipped, not failed, when `TRUSTY_POLICY_PG_URL` is unset, so
//! `cargo test --workspace` stays green on a machine with no database.
//! Same convention as `trusty-postgres`'s suite.
//!
//! ```text
//! TRUSTY_POLICY_PG_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \
//! cargo test -p trusty-policy-postgres --test integration
//! ```
//!
//! **The tests act as a non-superuser role.** A superuser bypasses row
//! level security entirely, so a suite that ran as `postgres` would pass
//! against a table with no protection at all.
use postgres::Client;
use trusty_policy::{Grant, Limits, Tier};
use trusty_policy_postgres::{CompilationReport, GrantId, GrantStore, PolicyPgError};
fn url() -> Option<String> {
std::env::var("TRUSTY_POLICY_PG_URL").ok()
}
/// Serialises the suite against one database.
///
/// Every `GrantStore::open` applies the schema, and concurrent
/// `CREATE OR REPLACE FUNCTION` on the same function collides in
/// `pg_proc` with "tuple concurrently updated". The tests also create
/// and drop roles, which are cluster-global however carefully the table
/// names are scoped.
///
/// Taken here rather than left to `--test-threads=1`, because a suite
/// that is only green when invoked a particular way is one plain
/// `cargo test` away from looking broken.
fn db_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Distinct names per test, so tests neither collide nor depend on
/// order.
struct Fixture {
table: String,
role: String,
}
impl Fixture {
fn new(tag: &str) -> Self {
Self {
table: format!("rls_probe_{tag}"),
role: format!("rls_role_{tag}"),
}
}
/// A protected table, a non-superuser role that can reach it, and no
/// grants at all.
fn set_up(&self, store: &mut GrantStore) -> Vec<String> {
let (table, role) = (&self.table, &self.role);
let drop_role = drop_role_sql(role);
store
.client()
.batch_execute(&format!(
"DROP TABLE IF EXISTS public.{table};
CREATE TABLE public.{table} (
id SERIAL PRIMARY KEY,
resource_scope TEXT NOT NULL,
note TEXT NOT NULL
);
INSERT INTO public.{table}(resource_scope, note) VALUES
('board:9f2/calendar/team', 'visible'),
('board:9f2/calendar/private', 'excluded'),
('board:other/calendar', 'someone else');
{drop_role}
CREATE ROLE {role} NOSUPERUSER NOBYPASSRLS;
-- SET ROLE requires membership. Supabase's `postgres` is
-- not a true superuser, so the connecting user has to be
-- granted the role it intends to act as.
GRANT {role} TO CURRENT_USER;
GRANT USAGE ON SCHEMA public TO {role};
GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO {role};
GRANT USAGE, SELECT ON SEQUENCE public.{table}_id_seq TO {role};
GRANT USAGE ON SCHEMA trusty_policy TO {role};"
))
.expect("fixture set-up");
store
.enforce(&format!("public.{table}"), "resource_scope")
.expect("enforce")
}
fn tear_down(&self, store: &mut GrantStore) {
let (table, role) = (&self.table, &self.role);
let drop_role = drop_role_sql(role);
let _ = store.client().batch_execute(&format!(
"DROP TABLE IF EXISTS public.{table};
{drop_role}"
));
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
}
/// Read the table the way the application would: as the unprivileged
/// role, with a subject set for the transaction only.
fn read_as(&self, client: &mut Client, subject: &str) -> Vec<String> {
let mut txn = client.transaction().expect("txn");
txn.execute(&format!("SET LOCAL ROLE {}", self.role), &[])
.expect("assume role");
txn.execute(
"SELECT set_config('trusty.subject', $1, true)",
&[&subject],
)
.expect("set subject");
let rows = txn
.query(
&format!("SELECT note FROM public.{} ORDER BY id", self.table),
&[],
)
.expect("select");
let notes = rows.iter().map(|r| r.get::<_, String>(0)).collect();
txn.rollback().expect("rollback");
notes
}
}
/// Drop a role only if it exists.
///
/// `DROP ROLE IF EXISTS` tolerates absence, but `DROP OWNED BY` does not
/// -- and the grants a role holds must go before the role can. So both
/// have to sit behind one existence check rather than relying on
/// IF EXISTS.
fn drop_role_sql(role: &str) -> String {
format!(
"DO $do$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
-- In PG16+, ADMIN OPTION on a role is not the same as
-- holding its privileges, and DROP OWNED BY needs the
-- latter. Creating the role is not enough.
EXECUTE 'GRANT {role} TO CURRENT_USER';
EXECUTE 'DROP OWNED BY {role}';
EXECUTE 'DROP ROLE {role}';
END IF;
END $do$;"
)
}
/// Issue a grant, registering its principals first.
///
/// `permits()` refuses for a subject that is not a registered, live
/// principal — identity before permission — so a test that only issued
/// a grant would be testing the registration check rather than the rule
/// it names.
trait Registering {
fn issue_registered(
&mut self,
grant: &Grant,
) -> Result<(GrantId, CompilationReport), PolicyPgError>;
}
impl Registering for GrantStore {
fn issue_registered(
&mut self,
grant: &Grant,
) -> Result<(GrantId, CompilationReport), PolicyPgError> {
for who in [Some(&grant.subject), grant.acts_for.as_ref()].into_iter().flatten() {
let kind = who.split(':').next().unwrap_or("agent");
self.register_principal(who, kind, "")?;
}
self.issue(grant)
}
}
fn grant(subject: &str, scope: &str, tier: Tier) -> Grant {
Grant {
why: Some("integration fixture".into()),
subject: subject.into(),
acts_for: None,
scope: scope.into(),
tier,
excludes: Vec::new(),
limits: Limits::default(),
// Far future; the expiry test overrides this deliberately.
expires_at: 4_102_444_800,
}
}
// ---------------------------------------------------------------------
// The acceptance criterion.
// ---------------------------------------------------------------------
#[test]
fn a_read_the_grant_excludes_fails_at_the_database() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("excl");
fx.set_up(&mut store);
let mut g = grant("agent:excl", "board:9f2/calendar", Tier::Read);
g.excludes = vec!["board:9f2/calendar/private".into()];
store.issue_registered(&g).expect("issue");
let seen = fx.read_as(store.client(), "agent:excl");
fx.tear_down(&mut store);
assert!(
!seen.contains(&"excluded".to_string()),
"an excluded resource must be unreadable AT THE DATABASE, not merely \
refused by an application check that could be bypassed; saw {seen:?}"
);
}
#[test]
fn an_exclusion_is_not_overridden_by_a_wider_grant() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("wider");
fx.set_up(&mut store);
let mut narrow = grant("agent:wider", "board:9f2/calendar/team", Tier::Read);
narrow.excludes = vec!["board:9f2/calendar/private".into()];
store.issue_registered(&narrow).expect("issue narrow");
// Issued afterwards, covering strictly more, and carrying no
// exclusion of its own.
let wide = grant("agent:wider", "board:9f2", Tier::Read);
store.issue_registered(&wide).expect("issue wide");
let seen = fx.read_as(store.client(), "agent:wider");
fx.tear_down(&mut store);
assert!(
!seen.contains(&"excluded".to_string()),
"adding a wider grant later must not reopen what an exclusion shut; \
saw {seen:?}"
);
assert!(
seen.contains(&"visible".to_string()),
"the wider grant should still permit everything it covers that is not \
excluded; saw {seen:?}"
);
}
#[test]
fn absent_policy_is_not_permission() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("deny");
fx.set_up(&mut store);
let with_no_grant = fx.read_as(store.client(), "agent:nobody");
let with_no_subject = fx.read_as(store.client(), "");
fx.tear_down(&mut store);
assert!(
with_no_grant.is_empty(),
"a subject with no grant must see nothing, not fall back to something \
wider; saw {with_no_grant:?}"
);
assert!(
with_no_subject.is_empty(),
"code that forgets to say who it is must get nothing rather than \
everything; saw {with_no_subject:?}"
);
}
#[test]
fn a_scope_containing_a_like_metacharacter_does_not_widen_itself() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("meta");
fx.set_up(&mut store);
// Under LIKE semantics '%' matches everything and this would read
// the whole table.
store
.issue(&grant("agent:meta", "%", Tier::Read))
.expect("issue");
let wildcard = fx.read_as(store.client(), "agent:meta");
store
.issue(&grant("agent:meta2", "board:9f2/calendar/_eam", Tier::Read))
.expect("issue");
let underscore = fx.read_as(store.client(), "agent:meta2");
fx.tear_down(&mut store);
assert!(
wildcard.is_empty(),
"a scope of '%' is a scope literally named '%', not a wildcard; saw \
{wildcard:?}"
);
assert!(
underscore.is_empty(),
"'_' must not match an arbitrary character, or a near-miss scope would \
silently grant its neighbour; saw {underscore:?}"
);
}
#[test]
fn a_sibling_whose_name_merely_starts_the_same_is_not_covered() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("sib");
fx.set_up(&mut store);
// 'board:9f2/calendar' must NOT cover 'board:9f2/calendar/team' via a
// bare prefix test that ignores the separator -- it does cover it
// here, legitimately -- but 'board:o' must not cover 'board:other'.
store
.issue(&grant("agent:sib", "board:o", Tier::Read))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:sib");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"containment must break on a path separator; without it, 'board:o' \
would grant 'board:other/calendar'; saw {seen:?}"
);
}
#[test]
fn an_expired_grant_stops_permitting_at_the_wall_clock() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("exp");
fx.set_up(&mut store);
let mut g = grant("agent:exp", "board:9f2/calendar", Tier::Read);
g.expires_at = 1_000_000_000; // 2001, comfortably past
store.issue_registered(&g).expect("issue");
let seen = fx.read_as(store.client(), "agent:exp");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"expiry is wall clock and independent of whether work finished; saw \
{seen:?}"
);
}
#[test]
fn revoking_a_grant_takes_effect_without_ddl() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("rev");
fx.set_up(&mut store);
let g = grant("agent:rev", "board:9f2/calendar", Tier::Read);
let (id, _) = store.issue_registered(&g).expect("issue");
let before = fx.read_as(store.client(), "agent:rev");
let revoked = store.revoke(&id).expect("revoke");
let after = fx.read_as(store.client(), "agent:rev");
let twice = store.revoke(&id).expect("revoke again");
fx.tear_down(&mut store);
assert!(
!before.is_empty(),
"the grant must permit something before revocation, or this test \
proves nothing"
);
assert!(revoked, "revoking a live grant must report that it did so");
assert!(
after.is_empty(),
"revocation must take effect at the next decision, with no DDL run; \
saw {after:?}"
);
assert!(
!twice,
"revoking what is already revoked must report that nothing changed, \
rather than reassuring the caller"
);
}
#[test]
fn an_agent_cannot_exceed_the_principal_it_acts_for() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("deleg");
fx.set_up(&mut store);
store
.client()
.batch_execute(
"INSERT INTO trusty_policy.principal(id, kind) VALUES
('agent:d1', 'agent'), ('user:d1', 'user')
ON CONFLICT DO NOTHING;
INSERT INTO trusty_policy.delegation(agent_id, acts_for_id) VALUES
('agent:d1', 'user:d1')
ON CONFLICT DO NOTHING;",
)
.expect("principals");
// The agent's own grant is wider than the principal's.
let mut wide = grant("agent:d1", "board:9f2", Tier::Read);
wide.acts_for = Some("user:d1".into());
store.issue_registered(&wide).expect("issue agent");
store
.issue(&grant("user:d1", "board:9f2/calendar/team", Tier::Read))
.expect("issue principal");
let seen = fx.read_as(store.client(), "agent:d1");
// Now revoke the delegation and try again.
store
.client()
.batch_execute(
"UPDATE trusty_policy.delegation SET revoked_at = now()
WHERE agent_id = 'agent:d1'",
)
.expect("revoke delegation");
let after_revoke = fx.read_as(store.client(), "agent:d1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation; DELETE FROM trusty_policy.principal")
.expect("clean principals");
fx.tear_down(&mut store);
assert_eq!(
seen,
vec!["visible".to_string()],
"the agent must be held to the intersection: its own grant covers \
board:9f2 entirely, but the principal only permits the team \
calendar; saw {seen:?}"
);
assert!(
after_revoke.is_empty(),
"revoking the delegation must stop the agent acting at all, without \
touching either grant; saw {after_revoke:?}"
);
}
#[test]
fn a_grant_for_one_verb_does_not_authorise_another() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("verb");
fx.set_up(&mut store);
// Delete is the most destructive tier. If tiers implied one another
// by ordering, this would also confer read.
store
.issue(&grant("agent:verb", "board:9f2/calendar", Tier::Delete))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:verb");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"tier maps 1:1 onto the SQL verbs and deny-by-default governs the rest, \
so a grant to delete must not silently confer the ability to read \
everything deletable; saw {seen:?}"
);
}
#[test]
fn enforce_is_idempotent_and_forces_row_level_security() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("force");
let first = fx.set_up(&mut store);
// Re-running must converge, not fail on an existing policy.
let second = store
.enforce(&format!("public.{}", fx.table), "resource_scope")
.expect("second enforce");
let forced: bool = store
.client()
.query_one(
"SELECT relforcerowsecurity FROM pg_class WHERE oid = to_regclass($1)",
&[&format!("public.{}", fx.table)],
)
.expect("pg_class")
.get(0);
let policies: i64 = store
.client()
.query_one(
"SELECT count(*) FROM pg_policies
WHERE schemaname = 'public' AND tablename = $1",
&[&fx.table],
)
.expect("pg_policies")
.get(0);
fx.tear_down(&mut store);
assert_eq!(first, second, "enforce must be deterministic across runs");
assert!(
forced,
"without FORCE, the table owner bypasses every policy and the \
protection is decorative"
);
assert_eq!(
policies, 4,
"one policy per verb, replaced rather than accumulated on re-run"
);
}
#[test]
fn an_unknown_relation_is_refused_rather_than_quoted() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
assert!(
store.enforce("public.no_such_table_here", "resource_scope").is_err(),
"enabling row level security on a table that does not exist would \
silently protect nothing"
);
assert!(
store.enforce("public.no_such_table_here", "'; DROP TABLE x; --").is_err(),
"an identifier that does not resolve must be refused, never escaped \
and used"
);
}
#[test]
fn issuing_the_same_grant_twice_leaves_one_row() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let g = grant("agent:idem", "board:9f2/calendar", Tier::Read);
let (first, report) = store.issue_registered(&g).expect("issue");
let (second, _) = store.issue_registered(&g).expect("issue again");
let count: i64 = store
.client()
.query_one(
"SELECT count(*) FROM trusty_policy.permission_grant WHERE subject = $1",
&[&"agent:idem"],
)
.expect("count")
.get(0);
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
assert_eq!(first, second, "identity is content, so it must not vary");
assert_eq!(
count, 1,
"a content-addressed id makes re-issuing idempotent rather than \
quietly accumulating duplicate permissions"
);
assert!(
report.is_total(),
"this grant sets no limits, so nothing about it went unenforced"
);
}
#[test]
fn the_stored_canonical_form_reproduces_the_id() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let g = grant("agent:canon", "board:9f2/calendar", Tier::Read);
let (id, _) = store.issue_registered(&g).expect("issue");
let stored: String = store
.client()
.query_one(
"SELECT canonical_json FROM trusty_policy.permission_grant WHERE id = $1",
&[&id.as_str()],
)
.expect("select")
.get(0);
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.permission_grant");
assert_eq!(
GrantId::of_canonical(&stored),
id,
"the bytes in the row must be the bytes that were hashed, or a \
verifier holding the signed grant could not find or check its row"
);
}
#[test]
fn a_registered_principal_with_no_grant_still_sees_nothing() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("reg");
fx.set_up(&mut store);
// Registration is identity, not permission. Without this test,
// `absent_policy_is_not_permission` would pass merely because the
// subject was unregistered, and the deny-by-default rule it is named
// for would go untested.
store
.register_principal("agent:registered", "agent", "has an identity, no grant")
.expect("register");
let seen = fx.read_as(store.client(), "agent:registered");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"being someone is not the same as being allowed something; saw {seen:?}"
);
}
#[test]
fn an_unregistered_principal_refuses_even_holding_a_grant() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("unreg");
fx.set_up(&mut store);
// Issued WITHOUT registering: a grant to nobody.
store
.issue(&grant("agent:ghost", "board:9f2/calendar", Tier::Read))
.expect("issue");
let seen = fx.read_as(store.client(), "agent:ghost");
fx.tear_down(&mut store);
assert!(
seen.is_empty(),
"a grant held by a principal who was never registered is a grant to \
nobody, and absent policy is not permission; saw {seen:?}"
);
}
#[test]
fn revoking_a_principal_stops_every_grant_it_holds_at_once() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("prev");
fx.set_up(&mut store);
// Two separate grants, so this tests the blunt instrument rather
// than a single revocation wearing a different hat.
store
.issue_registered(&grant("agent:doomed", "board:9f2/calendar", Tier::Read))
.expect("issue one");
store
.issue_registered(&grant("agent:doomed", "board:other", Tier::Read))
.expect("issue two");
let before = fx.read_as(store.client(), "agent:doomed");
let revoked = store.revoke_principal("agent:doomed").expect("revoke");
let after = fx.read_as(store.client(), "agent:doomed");
let twice = store.revoke_principal("agent:doomed").expect("revoke again");
let survivors: i64 = store
.client()
.query_one(
"SELECT count(*) FROM trusty_policy.permission_grant WHERE subject = $1",
&[&"agent:doomed"],
)
.expect("count")
.get(0);
fx.tear_down(&mut store);
assert_eq!(
before.len(),
3,
"both grants must permit something first, or this proves nothing; \
saw {before:?}"
);
assert!(revoked, "revoking a live principal must report that it did so");
assert!(
after.is_empty(),
"killing the key must end the agent's authority entirely, not grant \
by grant -- revoking them one at a time races whoever is using them; \
saw {after:?}"
);
assert!(!twice, "revoking what is already revoked must report no change");
assert_eq!(
survivors, 2,
"the grant rows must survive revocation; an audit asks what was \
permitted last March and a deleted row cannot answer"
);
}
#[test]
fn revoking_an_agent_leaves_the_principal_it_acted_for_untouched() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("sever");
fx.set_up(&mut store);
let mut agent = grant("agent:s1", "board:9f2", Tier::Read);
agent.acts_for = Some("user:s1".into());
store.issue_registered(&agent).expect("issue agent");
store
.issue_registered(&grant("user:s1", "board:9f2/calendar/team", Tier::Read))
.expect("issue principal");
store.delegate("agent:s1", "user:s1").expect("delegate");
let agent_before = fx.read_as(store.client(), "agent:s1");
store.revoke_principal("agent:s1").expect("revoke agent");
let agent_after = fx.read_as(store.client(), "agent:s1");
let principal_after = fx.read_as(store.client(), "user:s1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation")
.expect("clean");
fx.tear_down(&mut store);
assert!(!agent_before.is_empty(), "the agent must start able to act");
assert!(
agent_after.is_empty(),
"killing the agent's key must end its authority; saw {agent_after:?}"
);
assert_eq!(
principal_after,
vec!["visible".to_string()],
"and the person behind it must be untouched -- that asymmetry is the \
entire point of an agent never being a principal in its own right; \
saw {principal_after:?}"
);
}
#[test]
fn the_key_edge_records_a_reference_and_not_a_second_registry() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
store
.register_principal("agent:keyed", "agent", "")
.expect("register");
assert_eq!(
store.producer_oid_of("agent:keyed").expect("lookup"),
None,
"an unbound principal has no producer, and None is not the same as \
a producer named empty string"
);
store
.bind_key("agent:keyed", "1.3.6.1.4.1.99999.1")
.expect("bind");
let bound = store.producer_oid_of("agent:keyed").expect("lookup");
let unknown = store.bind_key("agent:missing", "1.3.6.1.4.1.99999.2");
let _ = store
.client()
.batch_execute("DELETE FROM trusty_policy.principal WHERE id = 'agent:keyed'");
assert_eq!(
bound.as_deref(),
Some("1.3.6.1.4.1.99999.1"),
"the edge must round-trip; resolving it to a public key is the \
producer registry's job, not this crate's"
);
assert!(
unknown.is_err(),
"a key bound to a principal that does not exist is a key nothing can \
revoke, so binding it must fail rather than create a dangling edge"
);
}
#[test]
fn a_delegation_must_be_recorded_before_an_agent_can_use_it() {
let Some(url) = url() else {
eprintln!("skipped: TRUSTY_POLICY_PG_URL unset");
return;
};
let _db = db_lock();
let mut store = GrantStore::open(&url).expect("open");
let fx = Fixture::new("edge");
fx.set_up(&mut store);
let mut agent = grant("agent:e1", "board:9f2/calendar", Tier::Read);
agent.acts_for = Some("user:e1".into());
store.issue_registered(&agent).expect("issue agent");
store
.issue_registered(&grant("user:e1", "board:9f2/calendar", Tier::Read))
.expect("issue principal");
// Both grants exist and agree. The edge does not.
let without_edge = fx.read_as(store.client(), "agent:e1");
store.delegate("agent:e1", "user:e1").expect("delegate");
let with_edge = fx.read_as(store.client(), "agent:e1");
store
.revoke_delegation("agent:e1", "user:e1")
.expect("revoke delegation");
let after_revoke = fx.read_as(store.client(), "agent:e1");
store
.client()
.batch_execute("DELETE FROM trusty_policy.delegation")
.expect("clean");
fx.tear_down(&mut store);
assert!(
without_edge.is_empty(),
"two matching grants are not a delegation; the authority to act for \
someone has to be recorded, or it is merely claimed; saw \
{without_edge:?}"
);
assert!(
!with_edge.is_empty(),
"with the edge recorded, the intersection should permit; saw {with_edge:?}"
);
assert!(
after_revoke.is_empty(),
"and withdrawing it must take effect without touching either grant; \
saw {after_revoke:?}"
);
}