commit 2d3b6763b1ecb75faaa6622765c19a082113c3f3
parent 749aec3207ec3d8b203b3f0d6720a1e1354c2d04
Author: Florian Dold <dold@taler.net>
Date: Mon, 31 Aug 2026 11:34:46 +0200
exchange: expose complete AML decision audit details
Diffstat:
6 files changed, 289 insertions(+), 30 deletions(-)
diff --git a/src/exchange/taler-exchange-httpd_get-aml-OFFICER_PUB-decisions.c b/src/exchange/taler-exchange-httpd_get-aml-OFFICER_PUB-decisions.c
@@ -41,6 +41,10 @@
* @param records closure
* @param row_id current row in AML status table
* @param justification human-readable reason for the decision
+ * @param new_measures names of measures requested immediately
+ * @param decider_pub public key of the deciding officer, NULL for automated outcomes
+ * @param decider_name name of the deciding officer at decision time
+ * @param kyc_attributes_rowid row of submitted attributes linked to the decision
* @param h_payto account for which the attribute data is stored
* @param decision_time when was the decision taken
* @param expiration_time when will the rules expire
@@ -57,6 +61,10 @@ record_cb (
json_t *records,
uint64_t row_id,
const char *justification,
+ const char *new_measures,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const char *decider_name,
+ const uint64_t *kyc_attributes_rowid,
const struct TALER_NormalizedPaytoHashP *h_payto,
struct GNUNET_TIME_Timestamp decision_time,
struct GNUNET_TIME_Absolute expiration_time,
@@ -67,34 +75,52 @@ record_cb (
struct TALER_FullPayto payto,
const json_t *account_rules)
{
+ json_t *record;
+
+ record = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_data_auto ("h_payto",
+ h_payto),
+ TALER_JSON_pack_full_payto ("full_payto",
+ payto),
+ GNUNET_JSON_pack_bool ("is_wallet",
+ is_wallet),
+ GNUNET_JSON_pack_uint64 ("rowid",
+ row_id),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("justification",
+ justification)),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("new_measures",
+ new_measures)),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_data_auto ("decider_pub",
+ decider_pub)),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("decider_name",
+ decider_name)),
+ GNUNET_JSON_pack_timestamp ("decision_time",
+ decision_time),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_object_incref ("properties",
+ (json_t *) jproperties)),
+ GNUNET_JSON_pack_object_incref ("limits",
+ (json_t *) account_rules),
+ GNUNET_JSON_pack_bool ("to_investigate",
+ to_investigate),
+ GNUNET_JSON_pack_bool ("is_active",
+ is_active));
+ GNUNET_assert (NULL != record);
+ if (NULL != kyc_attributes_rowid)
+ GNUNET_assert (0 ==
+ json_object_set_new (
+ record,
+ "kyc_attributes_rowid",
+ json_integer ((json_int_t) *kyc_attributes_rowid)));
GNUNET_assert (
0 ==
json_array_append_new (
records,
- GNUNET_JSON_PACK (
- GNUNET_JSON_pack_data_auto ("h_payto",
- h_payto),
- TALER_JSON_pack_full_payto ("full_payto",
- payto),
- GNUNET_JSON_pack_bool ("is_wallet",
- is_wallet),
- GNUNET_JSON_pack_uint64 ("rowid",
- row_id),
- GNUNET_JSON_pack_allow_null (
- GNUNET_JSON_pack_string ("justification",
- justification)),
- GNUNET_JSON_pack_timestamp ("decision_time",
- decision_time),
- GNUNET_JSON_pack_allow_null (
- GNUNET_JSON_pack_object_incref ("properties",
- (json_t *) jproperties)),
- GNUNET_JSON_pack_object_incref ("limits",
- (json_t *) account_rules),
- GNUNET_JSON_pack_bool ("to_investigate",
- to_investigate),
- GNUNET_JSON_pack_bool ("is_active",
- is_active)
- )));
+ record));
}
diff --git a/src/exchangedb/iterate_aml_decisions.c b/src/exchangedb/iterate_aml_decisions.c
@@ -78,7 +78,13 @@ handle_aml_result (void *cls,
{
struct TALER_NormalizedPaytoHashP h_payto;
uint64_t rowid;
+ uint64_t kyc_attributes_rowid;
char *justification = NULL;
+ char *new_measures = NULL;
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+ char *decider_name = NULL;
+ bool no_decider_pub;
+ bool no_kyc_attributes_rowid;
struct GNUNET_TIME_Timestamp decision_time;
struct GNUNET_TIME_Absolute expiration_time;
json_t *jproperties = NULL;
@@ -99,6 +105,22 @@ handle_aml_result (void *cls,
GNUNET_PQ_result_spec_string ("justification",
&justification),
NULL),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_string ("new_measure_name",
+ &new_measures),
+ NULL),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_auto_from_type ("decider_pub",
+ &decider_pub),
+ &no_decider_pub),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_string ("decider_name",
+ &decider_name),
+ NULL),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_uint64 ("kyc_attributes_serial_id",
+ &kyc_attributes_rowid),
+ &no_kyc_attributes_rowid),
GNUNET_PQ_result_spec_timestamp ("decision_time",
&decision_time),
GNUNET_PQ_result_spec_absolute_time ("expiration_time",
@@ -149,6 +171,10 @@ handle_aml_result (void *cls,
ctx->cb (ctx->cb_cls,
rowid,
justification,
+ new_measures,
+ no_decider_pub ? NULL : &decider_pub,
+ decider_name,
+ no_kyc_attributes_rowid ? NULL : &kyc_attributes_rowid,
&h_payto,
decision_time,
expiration_time,
@@ -211,6 +237,10 @@ TALER_EXCHANGEDB_iterate_aml_decisions (
" lo.outcome_serial_id"
",lo.h_payto"
",ah.justification"
+ ",lo.new_measure_name"
+ ",ah.decider_pub"
+ ",astaff.decider_name"
+ ",ah.kyc_attributes_serial_id"
",lo.decision_time"
",lo.expiration_time"
",lo.jproperties::TEXT"
@@ -226,6 +256,13 @@ TALER_EXCHANGEDB_iterate_aml_decisions (
" ON (lo.h_payto = wt.h_normalized_payto)"
" LEFT JOIN aml_history ah"
" USING (outcome_serial_id)"
+ " LEFT JOIN LATERAL"
+ " (SELECT s.decider_name"
+ " FROM aml_staff s"
+ " WHERE (s.decider_pub = ah.decider_pub)"
+ " AND (s.last_change <= lo.decision_time)"
+ " ORDER BY s.last_change DESC"
+ " LIMIT 1) astaff ON TRUE"
" WHERE (outcome_serial_id > $7)"
" AND ($1 OR (lo.h_payto = $2))"
" AND ($3 OR (lo.to_investigate = $4))"
@@ -238,6 +275,10 @@ TALER_EXCHANGEDB_iterate_aml_decisions (
" lo.outcome_serial_id"
",lo.h_payto"
",ah.justification"
+ ",lo.new_measure_name"
+ ",ah.decider_pub"
+ ",astaff.decider_name"
+ ",ah.kyc_attributes_serial_id"
",lo.decision_time"
",lo.expiration_time"
",lo.jproperties::TEXT"
@@ -253,6 +294,13 @@ TALER_EXCHANGEDB_iterate_aml_decisions (
" ON (lo.h_payto = wt.h_normalized_payto)"
" LEFT JOIN aml_history ah"
" USING (outcome_serial_id)"
+ " LEFT JOIN LATERAL"
+ " (SELECT s.decider_name"
+ " FROM aml_staff s"
+ " WHERE (s.decider_pub = ah.decider_pub)"
+ " AND (s.last_change <= lo.decision_time)"
+ " ORDER BY s.last_change DESC"
+ " LIMIT 1) astaff ON TRUE"
" WHERE lo.outcome_serial_id < $7"
" AND ($1 OR (lo.h_payto = $2))"
" AND ($3 OR (lo.to_investigate = $4))"
diff --git a/src/exchangedb/test_aml_history.c b/src/exchangedb/test_aml_history.c
@@ -123,6 +123,21 @@ struct HistoryContext
* Whether the last row named an officer.
*/
bool have_officer;
+
+ /**
+ * Whether the last decision exposed the officer's historical name.
+ */
+ char *officer_name;
+
+ /**
+ * Whether the last decision linked submitted attributes.
+ */
+ bool have_attributes;
+
+ /**
+ * Immediate measures of the last decision, owned by this struct.
+ */
+ char *new_measures;
};
@@ -133,6 +148,10 @@ struct HistoryContext
* @param outcome_serial_id row of the outcome the decision produced
* @param decision_time when the decision was taken
* @param justification why it was taken
+ * @param new_measures measures requested immediately
+ * @param decider_pub officer who made the decision
+ * @param decider_name officer name at decision time
+ * @param kyc_attributes_rowid linked submitted attributes
* @param decider_pub which officer took it
* @param jproperties new account properties
* @param jnew_rules new account rules
@@ -187,6 +206,10 @@ static void
decision_cb (void *cls,
uint64_t row_id,
const char *justification,
+ const char *new_measures,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const char *decider_name,
+ const uint64_t *kyc_attributes_rowid,
const struct TALER_NormalizedPaytoHashP *h_payto,
struct GNUNET_TIME_Timestamp decision_time,
struct GNUNET_TIME_Absolute expiration_time,
@@ -212,8 +235,18 @@ decision_cb (void *cls,
ctx->justification = (NULL == justification)
? NULL
: GNUNET_strdup (justification);
+ GNUNET_free (ctx->new_measures);
+ ctx->new_measures = (NULL == new_measures)
+ ? NULL
+ : GNUNET_strdup (new_measures);
ctx->to_investigate = to_investigate;
ctx->is_active = is_active;
+ ctx->have_officer = (NULL != decider_pub);
+ GNUNET_free (ctx->officer_name);
+ ctx->officer_name = (NULL == decider_name)
+ ? NULL
+ : GNUNET_strdup (decider_name);
+ ctx->have_attributes = (NULL != kyc_attributes_rowid);
}
@@ -316,6 +349,8 @@ struct DecisionStatus
* @param seed seed for the officer's signature
* @param when when the decision is taken, in seconds since the epoch
* @param justification why
+ * @param new_measures measures to request immediately
+ * @param add_attributes whether to link submitted attributes
* @param to_investigate whether staff should investigate
* @param[out] st set to the outcome
* @return transaction status
@@ -326,10 +361,14 @@ decide (struct TALER_EXCHANGEDB_PostgresContext *pg,
uint32_t seed,
uint64_t when,
const char *justification,
+ const char *new_measures,
+ bool add_attributes,
bool to_investigate,
struct DecisionStatus *st)
{
struct TALER_AmlOfficerSignatureP decider_sig;
+ struct GNUNET_HashCode attributes_hash;
+ const char encrypted_attributes[] = "encrypted attributes";
struct TALER_FullPayto null_payto = { NULL };
json_t *new_rules = make_rules ("decided");
/* the argument is declared `const char *[static 0]', so it must not
@@ -340,6 +379,9 @@ decide (struct TALER_EXCHANGEDB_PostgresContext *pg,
TDB_fill (&decider_sig,
sizeof (decider_sig),
seed);
+ TDB_fill (&attributes_hash,
+ sizeof (attributes_hash),
+ seed);
memset (st,
0,
sizeof (*st));
@@ -352,18 +394,18 @@ decide (struct TALER_EXCHANGEDB_PostgresContext *pg,
NULL,
new_rules,
to_investigate,
- NULL,
+ new_measures,
NULL,
justification,
decider,
(NULL == decider) ? NULL : &decider_sig,
0,
no_events,
- NULL,
- 0,
- NULL,
- NULL,
- GNUNET_TIME_UNIT_ZERO_TS,
+ add_attributes ? "test-form" : NULL,
+ add_attributes ? sizeof (encrypted_attributes) : 0,
+ add_attributes ? encrypted_attributes : NULL,
+ add_attributes ? &attributes_hash : NULL,
+ add_attributes ? ts (when + 3600) : GNUNET_TIME_UNIT_ZERO_TS,
&st->invalid_officer,
&st->unknown_account,
&st->last_date,
@@ -434,6 +476,8 @@ check_invalid_officer (struct TALER_EXCHANGEDB_PostgresContext *pg)
20,
1600000000,
"no such officer",
+ NULL,
+ false,
false,
&st));
FAILIF (! st.invalid_officer);
@@ -475,6 +519,8 @@ check_decide (struct TALER_EXCHANGEDB_PostgresContext *pg)
20,
1600000000,
"looks fine",
+ "review-a",
+ false,
false,
&st));
FAILIF (st.invalid_officer);
@@ -529,6 +575,8 @@ check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg)
21,
1500000000,
"too late",
+ NULL,
+ false,
false,
&st));
FAILIF (GNUNET_TIME_timestamp_cmp (st.last_date,
@@ -544,6 +592,8 @@ check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg)
22,
1600003600,
"investigate this",
+ "+review-a review-b",
+ true,
true,
&st));
FAILIF (2 != TDB_count (pg,
@@ -580,8 +630,23 @@ check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg)
static int
check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg)
{
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
struct HistoryContext ctx;
+ /* A later rename must not rewrite the name shown on older decisions. */
+ TDB_FILL (master_sig,
+ 30);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_officer (pg,
+ &officer_pub,
+ &master_sig,
+ "Alex Renamed",
+ true,
+ false,
+ ts (1700000000),
+ &previous_change));
+
memset (&ctx,
0,
sizeof (ctx));
@@ -596,7 +661,23 @@ check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg)
&ctx));
FAILIF_C (2 != ctx.total,
GNUNET_free (ctx.justification));
+ FAILIF_C (NULL == ctx.new_measures,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (0 != strcmp (ctx.new_measures,
+ "+review-a review-b"),
+ GNUNET_free (ctx.justification));
+ FAILIF_C (NULL == ctx.officer_name,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (0 != strcmp (ctx.officer_name,
+ "Alex Officer"),
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.have_officer,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.have_attributes,
+ GNUNET_free (ctx.justification));
GNUNET_free (ctx.justification);
+ GNUNET_free (ctx.new_measures);
+ GNUNET_free (ctx.officer_name);
/* only the newer decision asked for an investigation */
memset (&ctx,
@@ -617,6 +698,8 @@ check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg)
"investigate this"),
GNUNET_free (ctx.justification));
GNUNET_free (ctx.justification);
+ GNUNET_free (ctx.new_measures);
+ GNUNET_free (ctx.officer_name);
/* and only one of them is the active outcome */
memset (&ctx,
@@ -636,6 +719,8 @@ check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg)
FAILIF_C (! ctx.is_active,
GNUNET_free (ctx.justification));
GNUNET_free (ctx.justification);
+ GNUNET_free (ctx.new_measures);
+ GNUNET_free (ctx.officer_name);
/* an account nobody decided about has nothing */
{
@@ -748,6 +833,50 @@ check_history_builder (struct TALER_EXCHANGEDB_PostgresContext *pg)
/**
+ * Automated outcomes have no AML officer provenance.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_automated_outcome (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DecisionStatus st;
+ struct HistoryContext ctx = { 0 };
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ NULL,
+ 40,
+ 1800000000,
+ NULL,
+ NULL,
+ false,
+ false,
+ &st));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_YES,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (ctx.have_officer,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (NULL != ctx.officer_name,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+ GNUNET_free (ctx.new_measures);
+ GNUNET_free (ctx.officer_name);
+ return 0;
+}
+
+
+/**
* The checks to run, in order.
*/
static const struct TDB_Test tests[] = {
@@ -765,6 +894,8 @@ static const struct TDB_Test tests[] = {
&check_serial },
{ "aml-history-history-builder",
&check_history_builder },
+ { "aml-history-automated-outcome",
+ &check_automated_outcome },
{ NULL, NULL }
};
diff --git a/src/include/exchange-database/iterate_aml_decisions.h b/src/include/exchange-database/iterate_aml_decisions.h
@@ -32,6 +32,10 @@
* @param cls closure
* @param row_id current row in legitimization outcomes table
* @param justification human-readable reason for the decision, NULL if none is available
+ * @param new_measures names of measures requested immediately, NULL if none
+ * @param decider_pub public key of the deciding AML officer, NULL for automated outcomes
+ * @param decider_name name of the deciding AML officer at decision time, NULL if unavailable
+ * @param kyc_attributes_rowid row of submitted attributes linked to the decision, NULL if none
* @param h_payto account for which the attribute data is stored
* @param decision_time when was the decision taken
* @param expiration_time when will the rules expire
@@ -54,6 +58,10 @@ typedef void
TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE *cls,
uint64_t row_id,
const char *justification,
+ const char *new_measures,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const char *decider_name,
+ const uint64_t *kyc_attributes_rowid,
const struct TALER_NormalizedPaytoHashP *h_payto,
struct GNUNET_TIME_Timestamp decision_time,
struct GNUNET_TIME_Absolute expiration_time,
@@ -72,6 +80,10 @@ typedef void
* @param cls closure
* @param row_id current row in legitimization outcomes table
* @param justification human-readable reason for the decision, NULL if none is available
+ * @param new_measures names of measures requested immediately, NULL if none
+ * @param decider_pub public key of the deciding AML officer, NULL for automated outcomes
+ * @param decider_name name of the deciding AML officer at decision time, NULL if unavailable
+ * @param kyc_attributes_rowid row of submitted attributes linked to the decision, NULL if none
* @param h_payto account for which the attribute data is stored
* @param decision_time when was the decision taken
* @param expiration_time when will the rules expire
@@ -88,6 +100,10 @@ typedef void
TALER_EXCHANGEDB_AML_DECISION_RESULT_CLOSURE *cls,
uint64_t row_id,
const char *justification,
+ const char *new_measures,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const char *decider_name,
+ const uint64_t *kyc_attributes_rowid,
const struct TALER_NormalizedPaytoHashP *h_payto,
struct GNUNET_TIME_Timestamp decision_time,
struct GNUNET_TIME_Absolute expiration_time,
diff --git a/src/include/taler/exchange/get-aml-OFFICER_PUB-decisions.h b/src/include/taler/exchange/get-aml-OFFICER_PUB-decisions.h
@@ -246,6 +246,28 @@ struct TALER_EXCHANGE_GetAmlDecisionsDecision
const char *justification;
/**
+ * Space-separated names of measures requested immediately. A leading
+ * plus sign means all listed measures are required. Can be NULL.
+ */
+ const char *new_measures;
+
+ /**
+ * Public key of the AML officer who made the decision. All zeros for an
+ * automated outcome.
+ */
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+
+ /**
+ * Name of the AML officer at the time of the decision. Can be NULL.
+ */
+ const char *decider_name;
+
+ /**
+ * Row ID of submitted attributes linked to this decision. Zero if none.
+ */
+ uint64_t kyc_attributes_rowid;
+
+ /**
* When was the decision made?
*/
struct GNUNET_TIME_Timestamp decision_time;
diff --git a/src/lib/exchange_api_get-aml-OFFICER_PUB-decisions.c b/src/lib/exchange_api_get-aml-OFFICER_PUB-decisions.c
@@ -301,6 +301,22 @@ parse_aml_decisions (
GNUNET_JSON_spec_string ("justification",
&decision->justification),
NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("new_measures",
+ &decision->new_measures),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_fixed_auto ("decider_pub",
+ &decision->decider_pub),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("decider_name",
+ &decision->decider_name),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_uint64 ("kyc_attributes_rowid",
+ &decision->kyc_attributes_rowid),
+ NULL),
GNUNET_JSON_spec_timestamp ("decision_time",
&decision->decision_time),
GNUNET_JSON_spec_mark_optional (