Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions crates/consolebook-server/migrations/0015_retention_administration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
-- ADR 0020; #65: retention administration, without a destruction path.
CREATE TABLE retention_authority_event (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user(id),
granted INTEGER NOT NULL CHECK (granted IN (0, 1)),
actor_user_id INTEGER NOT NULL REFERENCES user(id),
reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000),
recorded_at INTEGER NOT NULL
) STRICT;

CREATE TABLE retention_policy (
id INTEGER PRIMARY KEY,
record_class TEXT NOT NULL CHECK (record_class IN ('daily_report', 'weekly_summary', 'phase_evaluation', 'disposition_event')),
version_number INTEGER NOT NULL CHECK (version_number > 0),
supersedes_id INTEGER UNIQUE REFERENCES retention_policy(id),
authority TEXT NOT NULL CHECK (length(trim(authority, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 200),
retention_trigger TEXT NOT NULL CHECK (retention_trigger IN ('finalized_at', 'enrollment_closed_at', 'disposed_at')),
retention_days INTEGER NOT NULL CHECK (retention_days BETWEEN 0 AND 365250),
action TEXT NOT NULL CHECK (action IN ('retain', 'destroy')),
reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000),
created_by INTEGER NOT NULL REFERENCES user(id),
created_at INTEGER NOT NULL,
UNIQUE(record_class, version_number),
CHECK ((record_class = 'disposition_event') = (retention_trigger = 'disposed_at')),
CHECK (action = 'destroy' OR retention_days = 0),
CHECK ((version_number = 1) = (supersedes_id IS NULL))
) STRICT;

CREATE TRIGGER retention_policy_successor
BEFORE INSERT ON retention_policy
WHEN NEW.version_number != (SELECT COALESCE(MAX(version_number), 0) + 1 FROM retention_policy WHERE record_class = NEW.record_class)
OR NEW.supersedes_id IS NOT (SELECT id FROM retention_policy WHERE record_class = NEW.record_class ORDER BY version_number DESC LIMIT 1)
BEGIN
SELECT RAISE(ABORT, 'a retention policy supersedes the current version of its class');
END;

CREATE TABLE record_hold (
id INTEGER PRIMARY KEY,
enrollment_id INTEGER REFERENCES enrollment(id),
evaluation_record_id INTEGER REFERENCES evaluation_record(id),
-- No ids means installation; enrollment only means enrollment scope;
-- record only means that record. No implicit name or text matching.
kind TEXT NOT NULL CHECK (kind IN ('litigation', 'anticipated_litigation', 'audit', 'investigation', 'public_records_request', 'other')),
authority TEXT NOT NULL CHECK (length(trim(authority, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 200),
reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000),
created_by INTEGER NOT NULL REFERENCES user(id),
created_at INTEGER NOT NULL,
replaces_id INTEGER UNIQUE REFERENCES record_hold(id),
CHECK (enrollment_id IS NULL OR evaluation_record_id IS NULL)
) STRICT;

CREATE TABLE hold_release (
hold_id INTEGER PRIMARY KEY REFERENCES record_hold(id),
released_by INTEGER NOT NULL REFERENCES user(id),
released_at INTEGER NOT NULL,
reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000),
replacement_id INTEGER UNIQUE REFERENCES record_hold(id)
) STRICT;

CREATE TRIGGER record_hold_replace_active
BEFORE INSERT ON record_hold
WHEN NEW.replaces_id IS NOT NULL AND (
NOT EXISTS (SELECT 1 FROM record_hold WHERE id = NEW.replaces_id)
OR EXISTS (SELECT 1 FROM hold_release WHERE hold_id = NEW.replaces_id)
OR NEW.created_at < (SELECT created_at FROM record_hold WHERE id = NEW.replaces_id)
)
BEGIN
SELECT RAISE(ABORT, 'a released hold cannot be replaced');
END;

CREATE TRIGGER hold_release_chronology
BEFORE INSERT ON hold_release
WHEN NEW.released_at < (SELECT created_at FROM record_hold WHERE id = NEW.hold_id)
BEGIN
SELECT RAISE(ABORT, 'a hold release cannot precede its creation');
END;

CREATE TRIGGER hold_release_matches_replacement
BEFORE INSERT ON hold_release
WHEN NEW.replacement_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM record_hold h WHERE h.id = NEW.replacement_id AND h.replaces_id = NEW.hold_id
AND h.created_by = NEW.released_by AND h.created_at = NEW.released_at AND h.reason = NEW.reason
)
BEGIN
SELECT RAISE(ABORT, 'a replacement release names its attributed successor');
END;

CREATE TRIGGER record_hold_replace_releases_previous
AFTER INSERT ON record_hold
WHEN NEW.replaces_id IS NOT NULL
BEGIN
INSERT INTO hold_release (hold_id, released_by, released_at, reason, replacement_id)
VALUES (NEW.replaces_id, NEW.created_by, NEW.created_at, NEW.reason, NEW.id);
END;

CREATE INDEX record_hold_enrollment ON record_hold(enrollment_id);
CREATE INDEX record_hold_record ON record_hold(evaluation_record_id);

CREATE TRIGGER retention_authority_event_no_update
BEFORE UPDATE ON retention_authority_event
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER retention_authority_event_no_delete
BEFORE DELETE ON retention_authority_event
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER retention_policy_no_update
BEFORE UPDATE ON retention_policy
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER retention_policy_no_delete
BEFORE DELETE ON retention_policy
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER record_hold_no_update
BEFORE UPDATE ON record_hold
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER record_hold_no_delete
BEFORE DELETE ON record_hold
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER hold_release_no_update
BEFORE UPDATE ON hold_release
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;

CREATE TRIGGER hold_release_no_delete
BEFORE DELETE ON hold_release
BEGIN
SELECT RAISE(ABORT, 'retention administration history is append-only');
END;
20 changes: 19 additions & 1 deletion crates/consolebook-server/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ use time::OffsetDateTime;
/// milestones extend this with record-lifecycle kinds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
RetentionAuthorityGranted,
RetentionAuthorityRevoked,
RetentionPolicyCreated,
RecordHoldCreated,
RecordHoldReplaced,
RecordHoldReleased,
SetupCompleted,
LoginSucceeded,
LoginFailed,
Expand Down Expand Up @@ -56,6 +62,12 @@ impl EventKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::RetentionAuthorityGranted => "retention_authority_granted",
Self::RetentionAuthorityRevoked => "retention_authority_revoked",
Self::RetentionPolicyCreated => "retention_policy_created",
Self::RecordHoldCreated => "record_hold_created",
Self::RecordHoldReplaced => "record_hold_replaced",
Self::RecordHoldReleased => "record_hold_released",
Self::SetupCompleted => "setup_completed",
Self::LoginSucceeded => "login_succeeded",
Self::LoginFailed => "login_failed",
Expand Down Expand Up @@ -103,6 +115,8 @@ impl EventKind {
/// append-only and must never block lawful disposition of its subjects.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Subject {
RetentionPolicy(i64),
RecordHold(i64),
Program(i64),
ProgramVersion(i64),
Enrollment(i64),
Expand All @@ -115,6 +129,8 @@ impl Subject {
#[must_use]
pub fn kind_str(self) -> &'static str {
match self {
Self::RetentionPolicy(_) => "retention_policy",
Self::RecordHold(_) => "record_hold",
Self::Program(_) => "program",
Self::ProgramVersion(_) => "program_version",
Self::Enrollment(_) => "enrollment",
Expand All @@ -127,7 +143,9 @@ impl Subject {
#[must_use]
pub fn id(self) -> i64 {
match self {
Self::Program(id)
Self::RetentionPolicy(id)
| Self::RecordHold(id)
| Self::Program(id)
| Self::ProgramVersion(id)
| Self::Enrollment(id)
| Self::Assignment(id)
Expand Down
2 changes: 2 additions & 0 deletions crates/consolebook-server/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
ManageUsers,
ManageRetention,
ManagePrograms,
AssignTraining,
ExportRecords,
Expand All @@ -27,6 +28,7 @@ impl Capability {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ManageRetention => "manage_retention",
Self::ManageUsers => "manage_users",
Self::ManagePrograms => "manage_programs",
Self::AssignTraining => "assign_training",
Expand Down
1 change: 1 addition & 0 deletions crates/consolebook-server/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub fn router(state: AppState) -> Router {
.merge(crate::training_http::routes())
.merge(crate::drafts_http::routes())
.merge(crate::exports_http::routes())
.merge(crate::retention_http::routes())
.fallback(crate::web_assets::serve)
.with_state(state)
}
Expand Down
3 changes: 3 additions & 0 deletions crates/consolebook-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ pub mod zip_container;

/// Version of the running build, as reported by `/api/health` and `doctor`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

pub mod retention;
pub mod retention_http;
76 changes: 76 additions & 0 deletions crates/consolebook-server/src/retention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Versioned policies, attributed holds, and explicit administration authority.
//! ADR 0020 defines this preparation stage; destruction is a separate workflow.
mod authority;
mod holds;
mod policies;
mod types;

pub use authority::{authority_history, set_authority};
pub use holds::{
ScopeOption, ScopeOptions, active_holds_for_record, create_hold, list_holds, release_hold,
replace_hold, scope_options,
};
pub use policies::{create_policy, list_policies};
pub use types::*;

use crate::{
capabilities::{self, Capability},
storage,
};
use anyhow::Result;
use sqlx::{Sqlite, SqliteConnection, SqlitePool, Transaction};

async fn authorized_write(
pool: &SqlitePool,
actor: i64,
capability: Capability,
) -> Result<Result<Transaction<'static, Sqlite>, RetentionRefusal>> {
let mut tx = storage::write_tx(pool).await?;
if !capabilities::user_has_on(&mut tx, actor, capability).await? {
return storage::refuse(tx, RetentionRefusal::CapabilityRequired).await;
}
Ok(Ok(tx))
}

async fn authorized_read(
pool: &SqlitePool,
actor: i64,
capability: Capability,
) -> Result<Result<Transaction<'static, Sqlite>, RetentionRefusal>> {
let mut tx = pool.begin().await?;
if !capabilities::user_has_on(&mut tx, actor, capability).await? {
return storage::refuse(tx, RetentionRefusal::CapabilityRequired).await;
}
Ok(Ok(tx))
}

fn text_problem(value: &str, field: &str, maximum: usize, problems: &mut Vec<String>) {
let length = value.trim().chars().count();
if length == 0 || length > maximum {
problems.push(format!("{field} must contain 1–{maximum} characters"));
}
}

async fn scope_exists(
conn: &mut SqliteConnection,
scope: &HoldScope,
) -> Result<Option<RetentionRefusal>> {
let (query, id, refusal) = match scope {
HoldScope::Installation => return Ok(None),
HoldScope::Enrollment { enrollment_id } => (
"SELECT 1 FROM enrollment WHERE id = ?1",
*enrollment_id,
RetentionRefusal::NoSuchEnrollment,
),
HoldScope::Record { record_id } => (
"SELECT 1 FROM evaluation_record WHERE id = ?1",
*record_id,
RetentionRefusal::NoSuchRecord,
),
};
let exists: Option<i64> = sqlx::query_scalar(query)
.bind(id)
.fetch_optional(conn)
.await?;
Ok(exists.is_none().then_some(refusal))
}
Loading