diff options
| -rw-r--r-- | docs/SCHEMA.md | 12 | ||||
| -rw-r--r-- | docs/STATE.md | 13 | ||||
| -rw-r--r-- | src/db.c | 29 | ||||
| -rw-r--r-- | src/db.h | 2 | ||||
| -rw-r--r-- | tests/test_core.c | 28 |
5 files changed, 73 insertions, 11 deletions
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 89dd0b1..2a85521 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -306,6 +306,11 @@ CREATE TABLE attachments ( UNIQUE (org_id, sha256, filename) ) STRICT; +CREATE TRIGGER attachments_no_update BEFORE UPDATE ON attachments +BEGIN SELECT RAISE(ABORT, 'attachments are append-only'); END; +CREATE TRIGGER attachments_no_delete BEFORE DELETE ON attachments +BEGIN SELECT RAISE(ABORT, 'attachments are append-only'); END; + CREATE TABLE voucher_attachments ( org_id INTEGER NOT NULL, voucher_id INTEGER NOT NULL, @@ -317,8 +322,11 @@ CREATE TABLE voucher_attachments ( ) STRICT; ``` -Attachments are content-addressed and immutable; linking is an insert into -`voucher_attachments` and is itself audited. Unlinked attachments form the +Attachments are content-addressed and immutable — the triggers abort updates +and deletes even for a root `sqlite3` session, and `audit.verify full:true` +re-hashes the content; linking is an insert into +`voucher_attachments` and is itself audited (the link table stays mutable so +underlag can be unlinked). Unlinked attachments form the inbox the TUI shows. The 7-year archive rule means content must never be garbage-collected; deduplication by hash keeps repeated receipts cheap. diff --git a/docs/STATE.md b/docs/STATE.md index 526c1a6..e210e1c 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -172,9 +172,9 @@ check. and `full:true` re-hashes attachment content; result carries `vouchers_checked`, `unbalanced_vouchers`, `attachments_checked` and the first bad voucher/audit/attachment id. TUI Revision shows both counts and - the bad ids. **Found while testing: `attachments` has no - append-only triggers** (COMPLIANCE.md §2 claims it does); content changes - are detected only by `audit.verify full:true`. + the bad ids. Fixed in schema v7: `attachments` now has + `no_update`/`no_delete` triggers; `audit.verify full:true` still detects + on-disk tampering. 5. ~~`report.general_ledger` and `report.voucher_list`~~ implemented (Huvudbok, Verifikationslista) with Kapitas-style TUI tables; the ledger API supports `accounts`/`from`/`to`, the list an optional `series`. @@ -207,7 +207,7 @@ check. ## Environment / how to run -- **Deployed**: `scripts/deploy.sh` (latest `v0.1.44`, healthy on nas). +- **Deployed**: `scripts/deploy.sh` (latest `v0.1.48`, healthy on nas). Live daemon `tls:bokf.makandra.eu:8788`, token `~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions like closing years must be done by the human in the TUI). Git remote @@ -254,8 +254,9 @@ check. - Never commit unless the human asks. - SQLite files must not be backed up live with restic; use `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots. -- Schema version is 6 (v3 moms rules; v4/v6 year info; v5 org - description/shares + board members); forward migrations are in `db.c`. +- Schema version is 7 (v3 moms rules; v4/v6 year info; v5 org + description/shares + board members; v7 attachments append-only triggers); + forward migrations are in `db.c`. ## Makandra driftstatus (org 2) @@ -206,6 +206,12 @@ static const char SCHEMA_V1[] = " UNIQUE (org_id, id)," " UNIQUE (org_id, sha256, filename)" ") STRICT;\n" + "CREATE TRIGGER attachments_no_update BEFORE UPDATE ON attachments BEGIN" + " SELECT RAISE(ABORT, 'attachments are append-only');" + "END;\n" + "CREATE TRIGGER attachments_no_delete BEFORE DELETE ON attachments BEGIN" + " SELECT RAISE(ABORT, 'attachments are append-only');" + "END;\n" "CREATE TABLE voucher_attachments (" " org_id INTEGER NOT NULL," @@ -433,6 +439,25 @@ static int db_upgrade_v6(sqlite3 *db, char **err) " DEFAULT ''", err); } +/* v7: attachments are immutable too — the content-addressed underlag may + never be updated or deleted, only linked/unlinked. */ +static int db_upgrade_v7(sqlite3 *db, char **err) +{ + if (db_exec(db, + "CREATE TRIGGER IF NOT EXISTS attachments_no_update" + " BEFORE UPDATE ON attachments BEGIN" + " SELECT RAISE(ABORT, 'attachments are append-only');" + "END", + err) != 0) + return -1; + return db_exec(db, + "CREATE TRIGGER IF NOT EXISTS attachments_no_delete" + " BEFORE DELETE ON attachments BEGIN" + " SELECT RAISE(ABORT, 'attachments are append-only');" + "END", + err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -457,6 +482,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 7 && db_upgrade_v7(db, err) != 0) { + db_exec(db, "ROLLBACK", NULL); + return -1; + } char *sql = sqlite3_mprintf( "UPDATE meta SET value='%d' WHERE key='schema_version'", BOKF_SCHEMA_VERSION); @@ -4,7 +4,7 @@ #include <sqlite3.h> #include <stdint.h> -#define BOKF_SCHEMA_VERSION 6 +#define BOKF_SCHEMA_VERSION 7 int db_open(const char *path, sqlite3 **out, char **err); int db_migrate(sqlite3 *db, char **err); diff --git a/tests/test_core.c b/tests/test_core.c index 56c70dd..70a1ab7 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -508,8 +508,18 @@ static void test_pre_migration_snapshot(const char *tmpdir, sqlite3_close(sdb); /* the reopened database is at the current version and usable */ + char ver[16]; + snprintf(ver, sizeof ver, "%d", BOKF_SCHEMA_VERSION); v = db_text(db, "SELECT value FROM meta WHERE key='schema_version'"); - CHECK(v && strcmp(v, "6") == 0); + CHECK(v && strcmp(v, ver) == 0); + free(v); + v = db_text(db, "SELECT name FROM sqlite_master WHERE type='trigger'" + " AND name='attachments_no_update'"); + CHECK(v && strcmp(v, "attachments_no_update") == 0); + free(v); + v = db_text(db, "SELECT name FROM sqlite_master WHERE type='trigger'" + " AND name='attachments_no_delete'"); + CHECK(v && strcmp(v, "attachments_no_delete") == 0); free(v); int64_t user_id = 0; CHECK(db_create_user(db, "migrated", "Migrated", "secret123", 0, &user_id, @@ -2414,7 +2424,21 @@ int main(void) sqlite3_finalize(ast); CHECK(attachment_id > 0); - /* attachments are re-hashed only with full:true */ + /* the append-only triggers block updates and deletes; dropping one + simulates a modified database file for the re-hash check */ + err = NULL; + CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'" + " WHERE id=%lld", (long long)attachment_id), + &err) != 0); + free(err); + err = NULL; + CHECK(db_exec(g_db, reqf("DELETE FROM attachments WHERE id=%lld", + (long long)attachment_id), + &err) != 0); + free(err); + err = NULL; + CHECK(db_exec(g_db, "DROP TRIGGER attachments_no_update", &err) == 0); + free(err); err = NULL; CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'" " WHERE id=%lld", (long long)attachment_id), |
