Trusty: provenance library workspace (initial import)
This commit is contained in:
20
crates/adapters/trusty-policy-postgres/Cargo.toml
Normal file
20
crates/adapters/trusty-policy-postgres/Cargo.toml
Normal 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 = []
|
||||
78
crates/adapters/trusty-policy-postgres/examples/emit_ddl.rs
Normal file
78
crates/adapters/trusty-policy-postgres/examples/emit_ddl.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
282
crates/adapters/trusty-policy-postgres/src/lib.rs
Normal file
282
crates/adapters/trusty-policy-postgres/src/lib.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
312
crates/adapters/trusty-policy-postgres/src/schema.rs
Normal file
312
crates/adapters/trusty-policy-postgres/src/schema.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
616
crates/adapters/trusty-policy-postgres/src/store.rs
Normal file
616
crates/adapters/trusty-policy-postgres/src/store.rs
Normal 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("ed, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
883
crates/adapters/trusty-policy-postgres/tests/integration.rs
Normal file
883
crates/adapters/trusty-policy-postgres/tests/integration.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user