diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 9cc09c6..8bf4289 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -81,6 +81,8 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install pinned Rust run: rustup toolchain install 1.97.1 --profile minimal --no-self-update + - name: Install pinned Rust components + run: rustup component add rustfmt clippy --toolchain 1.97.1 - name: Check formatting run: cargo +1.97.1 fmt --all -- --check - name: Run domain tests @@ -110,7 +112,7 @@ jobs: policy_count=$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \ --tuples-only --no-align \ --command "SELECT count(*) FROM pg_policies WHERE schemaname = 'public'") - test "$policy_count" = '15' + test "$policy_count" = '16' test "$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \ --tuples-only --no-align \ --command "SELECT count(*) FROM pg_constraint WHERE conname = 'completion_decision_registration_fk'")" = '1' @@ -232,3 +234,14 @@ jobs: "http://127.0.0.1:8080/v1/tenants/$tenant_id/learners/$learner_id/registrations/$registration_id/completion-decisions/$decision_id/credentials/$credential_id/revoke")" = '200' test "$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \ --tuples-only --no-align --command "SELECT count(*) FROM credential_record WHERE tenant_id = '$tenant_id' AND learner_id = '$learner_id' AND credential_status = 'revoked'")" = '1' + audit_count=$(psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \ + --tuples-only --no-align \ + --command "SELECT count(*) FROM audit_event_record WHERE tenant_id = '$tenant_id' AND event_digest <> ''") + test "$audit_count" = '4' + if psql --host 127.0.0.1 --username lms_kernel --dbname lms_kernel_test \ + --set ON_ERROR_STOP=1 \ + --command "BEGIN; SET LOCAL app.tenant_id = '$tenant_id'; UPDATE audit_event_record SET event_digest = 'tampered' WHERE tenant_id = '$tenant_id'; COMMIT;" \ + > /dev/null 2>&1; then + echo 'audit events must reject mutation' >&2 + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc8f64..7c509c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,4 @@ - Added idempotent credential reference revocation with tenant and decision-boundary checks. - Added versioned assessment-result reference handoff with external outcome status and passed-only completion evaluation. - Added registration-bound evidence references and idempotent assessment import retries. +- Added tenant-isolated append-only audit events with correlation, source version, and event digest for assessment import, completion publication, and credential lifecycle transitions. diff --git a/crates/lms_kernel/src/bin/lms_api.rs b/crates/lms_kernel/src/bin/lms_api.rs index 77cdedb..009f4b1 100644 --- a/crates/lms_kernel/src/bin/lms_api.rs +++ b/crates/lms_kernel/src/bin/lms_api.rs @@ -20,6 +20,7 @@ use lms_kernel::{ }; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions}; use uuid::Uuid; @@ -463,6 +464,64 @@ async fn begin_tenant_transaction( Ok(transaction) } +struct AuditEvent<'a> { + tenant_id: Uuid, + correlation_id: Uuid, + action_name: &'a str, + entity_type: &'a str, + entity_id: Uuid, + source_authority: &'a str, + source_version: &'a str, + occurred_at: DateTime, +} + +fn audit_event_digest(event: &AuditEvent<'_>) -> String { + let mut hasher = Sha256::new(); + for part in [ + event.tenant_id.to_string(), + event.correlation_id.to_string(), + event.action_name.to_owned(), + event.entity_type.to_owned(), + event.entity_id.to_string(), + event.source_authority.to_owned(), + event.source_version.to_owned(), + event.occurred_at.to_rfc3339(), + ] { + hasher.update(part.as_bytes()); + hasher.update([0_u8]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +async fn record_audit_event( + transaction: &mut Transaction<'_, Postgres>, + event: AuditEvent<'_>, +) -> Result<(), ApiError> { + let event_digest = audit_event_digest(&event); + sqlx::query( + "INSERT INTO audit_event_record \ + (tenant_id, correlation_id, action_name, entity_type, entity_id, \ + source_authority, source_version, event_digest, occurred_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(event.tenant_id) + .bind(event.correlation_id) + .bind(event.action_name) + .bind(event.entity_type) + .bind(event.entity_id) + .bind(event.source_authority) + .bind(event.source_version) + .bind(event_digest) + .bind(event.occurred_at) + .execute(&mut **transaction) + .await?; + Ok(()) +} + async fn create_affiliation( State(state): State, Path((tenant_id, learner_id)): Path<(Uuid, Uuid)>, @@ -1060,7 +1119,7 @@ struct EvidenceInsertRequest<'a> { async fn insert_evidence_reference( transaction: &mut Transaction<'_, Postgres>, request: EvidenceInsertRequest<'_>, -) -> Result { +) -> Result<(Uuid, bool), ApiError> { let evidence = sqlx::query( "INSERT INTO decision_evidence_reference \ (tenant_id, learner_id, learning_registration_id, evidence_kind, source_authority, \ @@ -1087,7 +1146,7 @@ async fn insert_evidence_reference( .fetch_optional(&mut **transaction) .await?; if let Some(evidence) = evidence { - return Ok(evidence.try_get("decision_evidence_reference_id")?); + return Ok((evidence.try_get("decision_evidence_reference_id")?, true)); } let idempotency_key = request.idempotency_key.ok_or(ApiError::Conflict)?; @@ -1119,7 +1178,7 @@ async fn insert_evidence_reference( if !same_request { return Err(ApiError::Conflict); } - Ok(existing.try_get("decision_evidence_reference_id")?) + Ok((existing.try_get("decision_evidence_reference_id")?, false)) } async fn create_evidence( @@ -1164,7 +1223,7 @@ async fn create_evidence( .as_ref() .map(|status| assessment_result_status_name(status).to_owned()); let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?; - let decision_evidence_reference_id = insert_evidence_reference( + let (decision_evidence_reference_id, _) = insert_evidence_reference( &mut transaction, EvidenceInsertRequest { tenant_id, @@ -1233,8 +1292,9 @@ async fn create_assessment_result( ) .map_err(map_kernel_error)?; let status_name = assessment_result_status_name(&assessment_result_status).to_owned(); + let correlation_id = Uuid::new_v4(); let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?; - let decision_evidence_reference_id = insert_evidence_reference( + let (decision_evidence_reference_id, inserted) = insert_evidence_reference( &mut transaction, EvidenceInsertRequest { tenant_id, @@ -1248,6 +1308,22 @@ async fn create_assessment_result( }, ) .await?; + if inserted { + record_audit_event( + &mut transaction, + AuditEvent { + tenant_id, + correlation_id, + action_name: "assessment_result_reference.recorded", + entity_type: "decision_evidence_reference", + entity_id: decision_evidence_reference_id, + source_authority: &request.assessment_authority, + source_version: &request.source_version, + occurred_at: observed_at, + }, + ) + .await?; + } transaction.commit().await?; Ok(( @@ -1288,6 +1364,7 @@ async fn create_completion_decision( )); } let evaluated_at = request.evaluated_at.unwrap_or_else(Utc::now); + let correlation_id = Uuid::new_v4(); let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?; let revision = sqlx::query( "SELECT completion_policy_id, revision_number, required_evidence_kinds \ @@ -1422,6 +1499,21 @@ async fn create_completion_decision( .bind(learning_registration_id) .execute(&mut *transaction) .await?; + let source_version = format!("policy-revision/{revision_number}"); + record_audit_event( + &mut transaction, + AuditEvent { + tenant_id, + correlation_id, + action_name: "completion_decision.published", + entity_type: "completion_decision", + entity_id: completion_decision_id, + source_authority: "lms-policy-engine", + source_version: &source_version, + occurred_at: evaluated_at, + }, + ) + .await?; transaction.commit().await?; Ok(( @@ -1458,6 +1550,7 @@ async fn create_credential( return Err(ApiError::BadRequest("credential references are required")); } + let correlation_id = Uuid::new_v4(); let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?; let credential = sqlx::query( "INSERT INTO credential_record \ @@ -1489,6 +1582,20 @@ async fn create_credential( ))?; let credential_record_id: Uuid = credential.try_get("credential_record_id")?; let issued_at: DateTime = credential.try_get("issued_at")?; + record_audit_event( + &mut transaction, + AuditEvent { + tenant_id, + correlation_id, + action_name: "credential_record.issued", + entity_type: "credential_record", + entity_id: credential_record_id, + source_authority: &request.credential_authority, + source_version: "credential-reference/v1", + occurred_at: issued_at, + }, + ) + .await?; transaction.commit().await?; Ok(( @@ -1527,6 +1634,7 @@ async fn revoke_credential( return Err(ApiError::BadRequest("credential references are required")); } + let correlation_id = Uuid::new_v4(); let mut transaction = begin_tenant_transaction(&state.pool, tenant_id).await?; let credential = sqlx::query( "UPDATE credential_record SET credential_status = 'revoked', revoked_at = now() \ @@ -1544,6 +1652,7 @@ async fn revoke_credential( .bind(credential_record_id) .fetch_optional(&mut *transaction) .await?; + let newly_revoked = credential.is_some(); let credential = match credential { Some(credential) => credential, None => sqlx::query( @@ -1574,6 +1683,22 @@ async fn revoke_credential( let credential_status: String = credential.try_get("credential_status")?; let issued_at: DateTime = credential.try_get("issued_at")?; let revoked_at: Option> = credential.try_get("revoked_at")?; + if newly_revoked && let Some(revoked_at) = revoked_at { + record_audit_event( + &mut transaction, + AuditEvent { + tenant_id, + correlation_id, + action_name: "credential_record.revoked", + entity_type: "credential_record", + entity_id: credential_record_id, + source_authority: &credential_authority, + source_version: "credential-reference/v1", + occurred_at: revoked_at, + }, + ) + .await?; + } transaction.commit().await?; Ok(( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c9870d3..fcafdd8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,4 +26,4 @@ Partner & Customer Academy: external learner onboarding, sponsor or self entitle ## Executable baseline -The current implementation slice is `crates/lms_kernel`: Rust domain rules enforce non-employee affiliations, effective dates, tenant/learner evidence boundaries, passed-assessment requirements, and replay fingerprints. `crates/lms_kernel/src/bin/lms_api.rs` provides health plus affiliation, offering, learner, entitlement, enrollment, registration, attempt, progress-projection, policy, evidence, assessment-result-reference, completion-decision, and credential-reference endpoints backed by the migration in `migrations/0001_learning_kernel.sql`. This is an executable bounded affiliation/registration/enrollment/progress/assessment-reference/completion/credential kernel, not yet the complete Partner & Customer Academy journey. Released external adapters, assessment execution/scoring, and browser E2E remain open gaps in `docs/product-technical-gap-baseline.md`. +The current implementation slice is `crates/lms_kernel`: Rust domain rules enforce non-employee affiliations, effective dates, tenant/learner evidence boundaries, passed-assessment requirements, and replay fingerprints. `crates/lms_kernel/src/bin/lms_api.rs` provides health plus affiliation, offering, learner, entitlement, enrollment, registration, attempt, progress-projection, policy, evidence, assessment-result-reference, completion-decision, credential-reference, and append-only audit-event writes backed by the migration in `migrations/0001_learning_kernel.sql`. This is an executable bounded affiliation/registration/enrollment/progress/assessment-reference/completion/credential/audit kernel, not yet the complete Partner & Customer Academy journey. Released external adapters, assessment execution/scoring, and browser E2E remain open gaps in `docs/product-technical-gap-baseline.md`. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 9bca734..b276a24 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -26,6 +26,7 @@ Initial entities: - `decision_evidence_reference` - `completion_decision` - `credential_record` +- `audit_event_record` A learner is not assumed to be an employee, login account, payer, or contracting organization. Optional employment linkage is represented as an effective-dated `learning_affiliation` or external worker reference with `valid_from` and `valid_to`; no employee row is synthesized for a non-employee learner. @@ -41,6 +42,8 @@ A learner is not assumed to be an employee, login account, payer, or contracting `credential_record` is a tenant-scoped reference projection issued only from a completed registration and its exact completion decision. It stores the credential authority, opaque external credential reference, lifecycle status, and issue/revocation timestamps; it does not store a badge payload or become the Open Badges/CLR authority. The four-column decision foreign key prevents a credential from combining a learner, registration, and decision from different rows. +`audit_event_record` is an append-only, tenant-scoped provenance record. It stores an opaque service actor, correlation UUID, action and entity identity, source authority/version, event digest, and occurrence time; it never copies learner, assessment, credential, or provider payloads. Row-level security and a mutation-rejecting trigger protect the record in addition to the API transaction boundary. + Cardinality baseline: ```text diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 565a236..a32a05b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -24,6 +24,7 @@ The next customer-visible milestone is not another document: a tenant-isolated l | PR [#10](https://github.com/ContextualWisdomLab/learning-management-platform/pull/10), implementation head `cb9a636` | Adds idempotent tenant-safe credential revocation from the same exact registration and completion decision. | It remains open with checks/review pending; it is not merged product evidence. | | PR [#11](https://github.com/ContextualWisdomLab/learning-management-platform/pull/11), implementation head `a2b72db` | Adds the versioned `assessment_result_reference/v1` boundary, external pass/fail status, and Rust rejection of non-passed assessment evidence. | It remains open with checks/review pending; it is not merged product evidence. | | PR [#12](https://github.com/ContextualWisdomLab/learning-management-platform/pull/12), implementation head `899d2dd` | Adds registration-bound evidence references and idempotent assessment import retries. | It remains open with checks/review pending; it is not merged product evidence. | +| PR [#13](https://github.com/ContextualWisdomLab/learning-management-platform/pull/13), implementation head `7514a87` | Adds tenant-isolated append-only audit events for assessment import, completion publication, and credential issue/revocation, with opaque actor, correlation UUID, source version, and event digest. | It remains open with checks/review pending; it is not merged product evidence. | | Issue [#2](https://github.com/ContextualWisdomLab/learning-management-platform/issues/2) | Defines the repository boundary, modular-monolith slices, PostgreSQL 3NF, adapters, accessibility, and evidence gates. | This is the foundation backlog, not delivered functionality. | | Issue [#3](https://github.com/ContextualWisdomLab/learning-management-platform/issues/3) | Defines the external-learner vertical and acceptance criteria for identity separation, effective dating, replayable completion, tenancy, and coverage. | This is the first product slice to implement after the bootstrap merges. | @@ -128,7 +129,7 @@ Production readiness requires CSAP and SOC 2 control mapping, SBOM and provenanc | G-04 | P0 | No time-aware affiliation or correction model | Requirements only | Effective-dated and replay/correction tests pass | Add valid-time and decision transaction metadata | | G-05 | P0 | No versioned external contracts | PR #11 adds the local `assessment_result_reference/v1` JSON schema/route; PR #12 adds registration binding and idempotent retries; no released provider client exists | Schemas, clients, provider-consumer contract tests, idempotent adapters | Add integration package, client, and outbox | | G-06 | P0 | No deterministic completion/evidence engine | PR #8 evaluates versioned policy/evidence in Rust; PR #11 rejects failed or inconclusive assessment evidence; PR #12 binds evidence to registration | Replay produces the same decision from policy/evidence versions on a real database | Add correction/supersession and provider contract tests | -| G-07 | P1 | No provenance, audit, or operational evidence | No runtime source | Correlation-linked audit and decision history with export | Add audit/provenance module | +| G-07 | P1 | No provenance, audit, or operational evidence | PR #13 adds tenant-isolated append-only audit events for assessment import, completion publication, and credential issue/revocation, with opaque actor, correlation UUID, source version, and event digest | Correlation-linked audit and decision history with export | Add request-correlation propagation, audit export, and operational receipts | | G-08 | P1 | No PostgreSQL hot-partition, retention, or rollback evidence | PRs #4–#9 provide 3NF tables, composite tenant FKs, RLS, and one embedded migration | Migration, RLS, load, retention, and rollback evidence | Measure append-heavy projections before adding partitions | | G-09 | P1 | No security/compliance control implementation | Standards profile only | CSAP/SOC 2/NIST control map with test receipts, SBOM, provenance | Add security and operability gates | | G-10 | P1 | No accessibility/UI/design-system surface | No frontend files | Storybook, token tests, browser interaction and i18n evidence | Start UI only after API journey is real | @@ -143,7 +144,7 @@ The current loop is: 1. PR #1: validate its current exact head, obtain an independent current-head review, then merge only when the live rules permit it. 2. Issue #2: add the executable modular-monolith foundation and repository gates. -3. Issue #3: stack the external-learner vertical on that foundation; PR #5 covers registration, PR #6 affiliation, PR #7 launch/progress, PR #8 completion persistence, PR #9 credential issuance, PR #10 credential revocation, PR #11 assessment-result reference handoff, and PR #12 retry/binding safety. +3. Issue #3: stack the external-learner vertical on that foundation; PR #5 covers registration, PR #6 affiliation, PR #7 launch/progress, PR #8 completion persistence, PR #9 credential issuance, PR #10 credential revocation, PR #11 assessment-result reference handoff, PR #12 retry/binding safety, and PR #13 audit/provenance events. 4. Add the product gaps found by runtime evidence as the next bounded PR, not as speculative scaffolding. ## Standards and research evidence diff --git a/migrations/0001_learning_kernel.sql b/migrations/0001_learning_kernel.sql index 0a47991..70cd5ff 100644 --- a/migrations/0001_learning_kernel.sql +++ b/migrations/0001_learning_kernel.sql @@ -264,6 +264,42 @@ CREATE TABLE credential_record ( CONSTRAINT credential_record_identity_unique UNIQUE (tenant_id, credential_record_id) ); +CREATE TABLE audit_event_record ( + audit_event_record_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES learning_tenant (tenant_id), + correlation_id uuid NOT NULL, + actor_authority text NOT NULL DEFAULT 'lms_api', + actor_subject_reference text NOT NULL DEFAULT 'service', + action_name text NOT NULL, + entity_type text NOT NULL, + entity_id uuid NOT NULL, + source_authority text NOT NULL, + source_version text NOT NULL, + event_digest text NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT audit_event_record_reference_check + CHECK (length(btrim(actor_authority)) > 0 + AND length(btrim(actor_subject_reference)) > 0 + AND length(btrim(action_name)) > 0 + AND length(btrim(entity_type)) > 0 + AND length(btrim(source_authority)) > 0 + AND length(btrim(source_version)) > 0 + AND length(btrim(event_digest)) > 0), + CONSTRAINT audit_event_record_identity_unique UNIQUE (tenant_id, audit_event_record_id) +); + +CREATE FUNCTION reject_audit_event_mutation() RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'audit_event_record is append-only'; +END; +$$; + +CREATE TRIGGER audit_event_record_append_only + BEFORE UPDATE OR DELETE ON audit_event_record + FOR EACH ROW EXECUTE FUNCTION reject_audit_event_mutation(); + CREATE TABLE learning_attempt ( learning_attempt_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id uuid NOT NULL, @@ -330,6 +366,7 @@ ALTER TABLE decision_evidence_reference ENABLE ROW LEVEL SECURITY; ALTER TABLE completion_decision ENABLE ROW LEVEL SECURITY; ALTER TABLE completion_decision_evidence ENABLE ROW LEVEL SECURITY; ALTER TABLE credential_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE audit_event_record ENABLE ROW LEVEL SECURITY; ALTER TABLE course_offering ENABLE ROW LEVEL SECURITY; ALTER TABLE access_entitlement ENABLE ROW LEVEL SECURITY; ALTER TABLE enrollment_record ENABLE ROW LEVEL SECURITY; @@ -355,6 +392,8 @@ CREATE POLICY completion_decision_evidence_tenant_policy ON completion_decision_ USING (tenant_id::text = current_setting('app.tenant_id', true)); CREATE POLICY credential_record_tenant_policy ON credential_record USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY audit_event_record_tenant_policy ON audit_event_record + USING (tenant_id::text = current_setting('app.tenant_id', true)); CREATE POLICY course_offering_tenant_policy ON course_offering USING (tenant_id::text = current_setting('app.tenant_id', true)); CREATE POLICY access_entitlement_tenant_policy ON access_entitlement @@ -378,6 +417,7 @@ ALTER TABLE decision_evidence_reference FORCE ROW LEVEL SECURITY; ALTER TABLE completion_decision FORCE ROW LEVEL SECURITY; ALTER TABLE completion_decision_evidence FORCE ROW LEVEL SECURITY; ALTER TABLE credential_record FORCE ROW LEVEL SECURITY; +ALTER TABLE audit_event_record FORCE ROW LEVEL SECURITY; ALTER TABLE course_offering FORCE ROW LEVEL SECURITY; ALTER TABLE access_entitlement FORCE ROW LEVEL SECURITY; ALTER TABLE enrollment_record FORCE ROW LEVEL SECURITY;