feat(approvals,audit): record the reviewer as the unchecked claim it is
The hub copies the reviewer string a client sends straight into decided_by (DecideApproval, ClearEventLog); nothing on the wire ties it to the authenticated caller. Studio filled it from the OS account, so an export read like non-repudiation while being an arbitrary client claim — the legal finding of the 2026-07-26 usertest panel. The real fix is hub-side (derive decided_by from CALLER_IDENTITY); that contract is written down in docs/reviewer-identity.md and needs a hub release. Until then Studio does the one thing it can do honestly and marks its own claim as a claim, inside the record: - data/reviewer_identity.dart is the single place that produces and reads the value; wire() is idempotent, so page and HubService may both normalise. Every write path funnels through HubService, so no surface can send a bare handle. - The inbox states before the decision who will be recorded, what that attribution is worth on this hub (from AuthStatus), and the literal string that lands in decided_by. An unreadable auth policy stays unreadable — never optimistic. - Reading back: a marked value shows its plain name plus an unchecked flag; an unmarked one (legacy row, CLI decision, or a future hub-derived identity) is not classified either way. - The audit wipe seeds the same kind of marked attribution into its chain.reset marker. When the hub starts deriving the value it overwrites the field and the prefix disappears by itself — no Studio release needed. Guards: reviewer_identity_test (the value) and approvals_reviewer_identity_test (every surface that writes or renders it, against the hermetic fake hub). Visual proof for both themes via the dialog-shot harness. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
415f8a7ddb
commit
ebc668d28d
15 changed files with 1129 additions and 41 deletions
|
|
@ -16,6 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
import '../l10n/app_localizations.dart';
|
||||
import 'flow_output.dart';
|
||||
import 'hub_auth_token.dart';
|
||||
import 'reviewer_identity.dart';
|
||||
export 'flow_output.dart';
|
||||
|
||||
class HubService {
|
||||
|
|
@ -649,12 +650,18 @@ class HubService {
|
|||
/// `production`; the gRPC error surfaces as an exception so
|
||||
/// the caller can show the operator why it was blocked.
|
||||
/// Returns `(purged, channel)` so the UI can confirm what
|
||||
/// just happened.
|
||||
/// just happened. [reviewer] is the operator's typed handle and
|
||||
/// goes out through [ReviewerIdentity.wire] for the same reason
|
||||
/// approvals do: the marker the hub seeds is read by auditors and
|
||||
/// must not claim a verified identity Studio cannot supply.
|
||||
Future<({int purged, String channel})> clearEventLog({
|
||||
required String reviewer,
|
||||
required String reason,
|
||||
}) async {
|
||||
final r = await _client.clearEventLog(reviewer: reviewer, reason: reason);
|
||||
final r = await _client.clearEventLog(
|
||||
reviewer: ReviewerIdentity.wire(reviewer),
|
||||
reason: reason,
|
||||
);
|
||||
return (purged: r.purged.toInt(), channel: r.channel);
|
||||
}
|
||||
|
||||
|
|
@ -1175,11 +1182,22 @@ class HubService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
/// Decide an approval. [reviewer] is the operator-facing handle;
|
||||
/// it leaves Studio through [ReviewerIdentity.wire], which labels
|
||||
/// it as the unchecked client claim it is — the hub stores the
|
||||
/// string verbatim, so the record has to carry its own trust
|
||||
/// level (see `lib/data/reviewer_identity.dart`). Every decide
|
||||
/// path (approvals page, batch actions, the flow-editor run
|
||||
/// driver) funnels through here so none of them can bypass that.
|
||||
Future<void> approve(String id, String reviewer) =>
|
||||
_client.approve(approvalId: id, reviewer: reviewer);
|
||||
_client.approve(approvalId: id, reviewer: ReviewerIdentity.wire(reviewer));
|
||||
|
||||
Future<void> reject(String id, String reviewer, String reason) =>
|
||||
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
||||
Future<void> reject(String id, String reviewer, String reason) => _client
|
||||
.reject(
|
||||
approvalId: id,
|
||||
reviewer: ReviewerIdentity.wire(reviewer),
|
||||
reason: reason,
|
||||
);
|
||||
|
||||
/// Detached-runs monitor snapshot: every tracked invocation
|
||||
/// (newest-first, optionally scoped to one [project]) plus whether
|
||||
|
|
|
|||
139
lib/data/reviewer_identity.dart
Normal file
139
lib/data/reviewer_identity.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Who decided? — the single place Studio answers that question.
|
||||
//
|
||||
// The hub copies the `reviewer` string a client sends straight into
|
||||
// `decided_by` (`DecideApproval` / `ClearEventLog`); nothing on the
|
||||
// wire ties that string to the authenticated caller. The legal
|
||||
// review of the approvals page (usertest 2026-07-26) called it out:
|
||||
// a value that reads like an identity but is an unchecked client
|
||||
// string lends the audit trail a non-repudiation it does not have.
|
||||
//
|
||||
// The real fix is hub-side — derive `decided_by` from the verified
|
||||
// caller (`CALLER_IDENTITY`, the same source `_caller` comes from)
|
||||
// and ignore what the client claims. That contract is written down
|
||||
// in `docs/reviewer-identity.md`; it needs a hub release.
|
||||
//
|
||||
// Until then Studio does the one thing it *can* do honestly: it
|
||||
// marks its own claim as a claim, inside the record. Every reviewer
|
||||
// string Studio sends carries the `unverified:` prefix, so an
|
||||
// export, an SQL reader or another client sees the trust level
|
||||
// without having to know how the deciding Studio was configured.
|
||||
// When the hub starts deriving the value it overwrites the field
|
||||
// and the prefix disappears by itself — no Studio release needed
|
||||
// to stop lying.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Marks a reviewer attribution as a client-side claim. Part of the
|
||||
/// recorded value, not just a UI decoration — see the file header.
|
||||
const String kUnverifiedReviewerPrefix = 'unverified:';
|
||||
|
||||
/// How much a reviewer attribution Studio is about to write is
|
||||
/// worth, derived from the hub's authentication policy.
|
||||
enum ReviewerAssurance {
|
||||
/// The hub accepts calls without credentials: a decision cannot
|
||||
/// be tied to anyone at all.
|
||||
anonymousHub,
|
||||
|
||||
/// The hub authenticates the connection (static token or JWT),
|
||||
/// but still records the reviewer name the client supplies.
|
||||
accessControlled,
|
||||
|
||||
/// Studio could not read the hub's auth policy — `AuthStatus` is
|
||||
/// admin-scoped, so a plain reviewer token gets PermissionDenied,
|
||||
/// and hubs older than the RPC answer UNIMPLEMENTED.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Map the hub's `anonymous_allowed` flag onto an assurance level.
|
||||
/// `null` = policy unreadable (denied / unsupported / offline).
|
||||
ReviewerAssurance reviewerAssuranceFor(bool? anonymousAllowed) =>
|
||||
switch (anonymousAllowed) {
|
||||
true => ReviewerAssurance.anonymousHub,
|
||||
false => ReviewerAssurance.accessControlled,
|
||||
null => ReviewerAssurance.unknown,
|
||||
};
|
||||
|
||||
/// What a stored `decided_by` value is worth when read back.
|
||||
enum RecordedReviewerTrust {
|
||||
/// Written by a Studio that labelled its own claim (`unverified:`).
|
||||
selfDeclared,
|
||||
|
||||
/// No trust marker: a legacy row, a CLI decision, or a future
|
||||
/// hub-derived identity. Studio does not know which — and must
|
||||
/// not present it as proven either way.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// A `decided_by` value split into what it says and what it is worth.
|
||||
@immutable
|
||||
class RecordedReviewer {
|
||||
/// The name without the trust marker — what a human should read.
|
||||
final String handle;
|
||||
final RecordedReviewerTrust trust;
|
||||
|
||||
const RecordedReviewer({required this.handle, required this.trust});
|
||||
|
||||
bool get isSelfDeclared => trust == RecordedReviewerTrust.selfDeclared;
|
||||
}
|
||||
|
||||
/// Resolves the reviewer identity Studio submits and reads recorded
|
||||
/// ones back. Never contacts the hub — the assurance level comes
|
||||
/// from the caller (see [reviewerAssuranceFor]).
|
||||
class ReviewerIdentity {
|
||||
ReviewerIdentity._();
|
||||
|
||||
static String? _debugHandle;
|
||||
|
||||
/// Test seam: pins [localHandle] so suites do not depend on the
|
||||
/// OS account of whoever runs them. `null` restores the default.
|
||||
@visibleForTesting
|
||||
static set debugHandle(String? value) => _debugHandle = value;
|
||||
|
||||
/// Human-readable handle of the operator sitting in front of
|
||||
/// Studio. The OS account is the closest stable label available
|
||||
/// locally — a label, never a proof, which is exactly why it
|
||||
/// leaves the machine through [wire].
|
||||
static String get localHandle {
|
||||
final pinned = _debugHandle;
|
||||
if (pinned != null) return pinned;
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'studio';
|
||||
return '$user@studio';
|
||||
}
|
||||
|
||||
/// The value that goes on the wire for [handle] (default:
|
||||
/// [localHandle]). Idempotent, so a value that already carries the
|
||||
/// marker — or one round-tripped through the UI — is not prefixed
|
||||
/// twice. An empty/blank handle falls back to [localHandle]: the
|
||||
/// hub rejects an empty reviewer, and a blank one would be a worse
|
||||
/// record than a labelled guess.
|
||||
static String wire([String? handle]) {
|
||||
final trimmed = (handle ?? localHandle).trim();
|
||||
final name = trimmed.isEmpty ? localHandle : trimmed;
|
||||
if (name.startsWith(kUnverifiedReviewerPrefix)) return name;
|
||||
return '$kUnverifiedReviewerPrefix$name';
|
||||
}
|
||||
|
||||
/// Split a recorded `decided_by` into name + trust.
|
||||
static RecordedReviewer parse(String recorded) {
|
||||
final value = recorded.trim();
|
||||
if (!value.startsWith(kUnverifiedReviewerPrefix)) {
|
||||
return RecordedReviewer(
|
||||
handle: value,
|
||||
trust: RecordedReviewerTrust.unknown,
|
||||
);
|
||||
}
|
||||
final handle = value.substring(kUnverifiedReviewerPrefix.length).trim();
|
||||
return RecordedReviewer(
|
||||
// A marker with nothing behind it still says something true
|
||||
// ("someone claimed nothing"); show the marker rather than an
|
||||
// empty cell.
|
||||
handle: handle.isEmpty ? value : handle,
|
||||
trust: RecordedReviewerTrust.selfDeclared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -674,6 +674,7 @@
|
|||
"auditClearDialogTitle": "Audit-Log löschen?",
|
||||
"auditClearDialogBody": "Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.",
|
||||
"auditClearReviewerLabel": "Prüfer",
|
||||
"auditClearReviewerHelper": "Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.",
|
||||
"auditClearReasonLabel": "Begründung (im chain.reset-Marker festgehalten)",
|
||||
"auditClearReasonHelper": "Erforderlich — Auditoren werden das lesen.",
|
||||
"auditClearLogButton": "Log löschen",
|
||||
|
|
@ -1102,6 +1103,13 @@
|
|||
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
||||
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
||||
"approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
|
||||
"approvalsReviewerLabel": "Sie entscheiden als",
|
||||
"approvalsReviewerUnverifiedPill": "nicht überprüft",
|
||||
"approvalsReviewerRecordedAs": "Wird gespeichert als: {value}",
|
||||
"approvalsReviewerNoteAnonymous": "Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.",
|
||||
"approvalsReviewerNoteAuthenticated": "Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.",
|
||||
"approvalsReviewerNoteUnknown": "Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.",
|
||||
"approvalsReviewerUnverifiedTooltip": "Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.",
|
||||
"approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.",
|
||||
"approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.",
|
||||
"approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?",
|
||||
|
|
|
|||
|
|
@ -692,6 +692,7 @@
|
|||
"auditClearDialogTitle": "Clear audit log?",
|
||||
"auditClearDialogBody": "Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.",
|
||||
"auditClearReviewerLabel": "Reviewer",
|
||||
"auditClearReviewerHelper": "Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.",
|
||||
"auditClearReasonLabel": "Reason (recorded in chain.reset marker)",
|
||||
"auditClearReasonHelper": "Required — auditors will read this.",
|
||||
"auditClearLogButton": "Clear log",
|
||||
|
|
@ -1120,6 +1121,13 @@
|
|||
"approvalsRequestFallback": "Approval required for this step",
|
||||
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
|
||||
"approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.",
|
||||
"approvalsReviewerLabel": "You are deciding as",
|
||||
"approvalsReviewerUnverifiedPill": "not verified",
|
||||
"approvalsReviewerRecordedAs": "Recorded as: {value}",
|
||||
"approvalsReviewerNoteAnonymous": "This hub accepts calls without credentials, so a decision cannot be proven to be anyone's. Studio therefore marks the name inside the record itself as an unchecked claim.",
|
||||
"approvalsReviewerNoteAuthenticated": "The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.",
|
||||
"approvalsReviewerNoteUnknown": "Studio could not read this hub's access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.",
|
||||
"approvalsReviewerUnverifiedTooltip": "Unchecked claim: this name comes from the deciding Studio, not from the hub.",
|
||||
"approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.",
|
||||
"approvalsRejectReasonHelper": "Required — recorded in the audit log.",
|
||||
"approvalsBatchNoDataTitle": "Approve without review data?",
|
||||
|
|
|
|||
|
|
@ -2384,6 +2384,12 @@ abstract class AppLocalizations {
|
|||
/// **'Reviewer'**
|
||||
String get auditClearReviewerLabel;
|
||||
|
||||
/// No description provided for @auditClearReviewerHelper.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.'**
|
||||
String get auditClearReviewerHelper;
|
||||
|
||||
/// No description provided for @auditClearReasonLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3560,6 +3566,48 @@ abstract class AppLocalizations {
|
|||
/// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'**
|
||||
String get approvalsIntroHelp;
|
||||
|
||||
/// No description provided for @approvalsReviewerLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You are deciding as'**
|
||||
String get approvalsReviewerLabel;
|
||||
|
||||
/// No description provided for @approvalsReviewerUnverifiedPill.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'not verified'**
|
||||
String get approvalsReviewerUnverifiedPill;
|
||||
|
||||
/// No description provided for @approvalsReviewerRecordedAs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recorded as: {value}'**
|
||||
String approvalsReviewerRecordedAs(Object value);
|
||||
|
||||
/// No description provided for @approvalsReviewerNoteAnonymous.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.'**
|
||||
String get approvalsReviewerNoteAnonymous;
|
||||
|
||||
/// No description provided for @approvalsReviewerNoteAuthenticated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.'**
|
||||
String get approvalsReviewerNoteAuthenticated;
|
||||
|
||||
/// No description provided for @approvalsReviewerNoteUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.'**
|
||||
String get approvalsReviewerNoteUnknown;
|
||||
|
||||
/// No description provided for @approvalsReviewerUnverifiedTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unchecked claim: this name comes from the deciding Studio, not from the hub.'**
|
||||
String get approvalsReviewerUnverifiedTooltip;
|
||||
|
||||
/// No description provided for @approvalsRejectDialogHelp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get auditClearReviewerLabel => 'Prüfer';
|
||||
|
||||
@override
|
||||
String get auditClearReviewerHelper =>
|
||||
'Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.';
|
||||
|
||||
@override
|
||||
String get auditClearReasonLabel =>
|
||||
'Begründung (im chain.reset-Marker festgehalten)';
|
||||
|
|
@ -2062,6 +2066,33 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get approvalsIntroHelp =>
|
||||
'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerLabel => 'Sie entscheiden als';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerUnverifiedPill => 'nicht überprüft';
|
||||
|
||||
@override
|
||||
String approvalsReviewerRecordedAs(Object value) {
|
||||
return 'Wird gespeichert als: $value';
|
||||
}
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteAnonymous =>
|
||||
'Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteAuthenticated =>
|
||||
'Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteUnknown =>
|
||||
'Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerUnverifiedTooltip =>
|
||||
'Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.';
|
||||
|
||||
@override
|
||||
String get approvalsRejectDialogHelp =>
|
||||
'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.';
|
||||
|
|
|
|||
|
|
@ -1364,6 +1364,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get auditClearReviewerLabel => 'Reviewer';
|
||||
|
||||
@override
|
||||
String get auditClearReviewerHelper =>
|
||||
'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.';
|
||||
|
||||
@override
|
||||
String get auditClearReasonLabel => 'Reason (recorded in chain.reset marker)';
|
||||
|
||||
|
|
@ -2066,6 +2070,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get approvalsIntroHelp =>
|
||||
'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerLabel => 'You are deciding as';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerUnverifiedPill => 'not verified';
|
||||
|
||||
@override
|
||||
String approvalsReviewerRecordedAs(Object value) {
|
||||
return 'Recorded as: $value';
|
||||
}
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteAnonymous =>
|
||||
'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteAuthenticated =>
|
||||
'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerNoteUnknown =>
|
||||
'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.';
|
||||
|
||||
@override
|
||||
String get approvalsReviewerUnverifiedTooltip =>
|
||||
'Unchecked claim: this name comes from the deciding Studio, not from the hub.';
|
||||
|
||||
@override
|
||||
String get approvalsRejectDialogHelp =>
|
||||
'Rejecting stops the process at this step and is recorded with your reason in the audit trail.';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/reviewer_identity.dart';
|
||||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -52,18 +52,26 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
/// and falls back to per-row Approve / Reject buttons.
|
||||
final Set<String> _selectedIds = <String>{};
|
||||
bool _batchInFlight = false;
|
||||
// Reviewer identity recorded in the audit log. Defaults to
|
||||
// the OS user (closest stable identity Studio has without an
|
||||
// auth backend); operators can override it per session.
|
||||
late final String _reviewer = _defaultReviewer();
|
||||
|
||||
static String _defaultReviewer() {
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'studio';
|
||||
return '$user@studio';
|
||||
}
|
||||
/// Operator-facing handle of whoever is deciding here. The OS
|
||||
/// account is a label, not an identity — [HubService.approve]
|
||||
/// marks it as an unchecked claim on the wire, and the strip
|
||||
/// above the inbox says so in plain words. See
|
||||
/// `lib/data/reviewer_identity.dart`.
|
||||
final String _reviewer = ReviewerIdentity.localHandle;
|
||||
|
||||
/// What actually goes into `decided_by` — the handle plus the
|
||||
/// marker saying it is an unchecked client claim. Computed here
|
||||
/// (not only inside [HubService], which normalises again as a
|
||||
/// backstop) because the strip above the inbox shows the operator
|
||||
/// this very string before they decide.
|
||||
String get _reviewerWire => ReviewerIdentity.wire(_reviewer);
|
||||
|
||||
/// What that attribution is worth on THIS hub. Starts unknown and
|
||||
/// stays unknown when the policy cannot be read (AuthStatus is
|
||||
/// admin-scoped, and old hubs answer UNIMPLEMENTED) — the strip
|
||||
/// has an honest line for each case.
|
||||
ReviewerAssurance _assurance = ReviewerAssurance.unknown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -71,6 +79,21 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Workspace.instance.addListener(_refresh);
|
||||
Workspace.instance.ensureLoaded();
|
||||
_refresh();
|
||||
_loadAssurance();
|
||||
}
|
||||
|
||||
Future<void> _loadAssurance() async {
|
||||
ReviewerAssurance resolved;
|
||||
try {
|
||||
final policy = await HubService.instance.authStatus();
|
||||
resolved = reviewerAssuranceFor(policy.anonymousAllowed);
|
||||
} catch (_) {
|
||||
// Denied / unsupported / hub gone: not knowing is a state of
|
||||
// its own, never an excuse to imply the attribution is sound.
|
||||
resolved = ReviewerAssurance.unknown;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _assurance = resolved);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -127,7 +150,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
}
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
await HubService.instance.approve(a.id, _reviewerWire);
|
||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -142,7 +165,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
final reason = await _promptReason(context);
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -205,7 +228,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Object? firstError;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
await HubService.instance.approve(a.id, _reviewerWire);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (e) {
|
||||
|
|
@ -235,7 +258,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Object? firstError;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (e) {
|
||||
|
|
@ -370,6 +393,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
children: [
|
||||
_PendingList(
|
||||
future: _pendingFuture,
|
||||
reviewer: _reviewer,
|
||||
assurance: _assurance,
|
||||
selectedIds: _selectedIds,
|
||||
batchInFlight: _batchInFlight,
|
||||
onApprove: _approve,
|
||||
|
|
@ -390,6 +415,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
|
||||
class _PendingList extends StatelessWidget {
|
||||
final Future<List<ApprovalRecord>> future;
|
||||
final String reviewer;
|
||||
final ReviewerAssurance assurance;
|
||||
final Set<String> selectedIds;
|
||||
final bool batchInFlight;
|
||||
final void Function(ApprovalRecord) onApprove;
|
||||
|
|
@ -403,6 +430,8 @@ class _PendingList extends StatelessWidget {
|
|||
|
||||
const _PendingList({
|
||||
required this.future,
|
||||
required this.reviewer,
|
||||
required this.assurance,
|
||||
required this.selectedIds,
|
||||
required this.batchInFlight,
|
||||
required this.onApprove,
|
||||
|
|
@ -453,6 +482,21 @@ class _PendingList extends StatelessWidget {
|
|||
),
|
||||
child: ChainInlineHelp(text: l.approvalsIntroHelp),
|
||||
),
|
||||
// Who the audit trail will name, and what that name is
|
||||
// worth — stated before the decision, not discovered
|
||||
// afterwards by a lawyer reading the export.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
ChainSpace.xl,
|
||||
ChainSpace.md,
|
||||
ChainSpace.xl,
|
||||
0,
|
||||
),
|
||||
child: _ReviewerIdentityStrip(
|
||||
handle: reviewer,
|
||||
assurance: assurance,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
|
|
@ -505,6 +549,108 @@ class _PendingList extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// "You are deciding as …" — names the attribution the audit trail
|
||||
/// will carry and, in one plain sentence, what it is worth on this
|
||||
/// hub. Studio cannot prove the identity (the hub stores whatever a
|
||||
/// client sends as `decided_by`), so the honest move is to say that
|
||||
/// out loud instead of letting the name pass for proof.
|
||||
class _ReviewerIdentityStrip extends StatelessWidget {
|
||||
final String handle;
|
||||
final ReviewerAssurance assurance;
|
||||
|
||||
const _ReviewerIdentityStrip({
|
||||
required this.handle,
|
||||
required this.assurance,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final note = switch (assurance) {
|
||||
ReviewerAssurance.anonymousHub => l.approvalsReviewerNoteAnonymous,
|
||||
ReviewerAssurance.accessControlled =>
|
||||
l.approvalsReviewerNoteAuthenticated,
|
||||
ReviewerAssurance.unknown => l.approvalsReviewerNoteUnknown,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(ChainSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.badge_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Wrap, not Row: a long handle plus the pill must
|
||||
// reflow instead of overflowing on narrow windows.
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: ChainSpace.sm,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
l.approvalsReviewerLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
handle,
|
||||
maxLines: 1,
|
||||
style: ChainTheme.mono(
|
||||
size: 12,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
ChainPill(
|
||||
label: l.approvalsReviewerUnverifiedPill,
|
||||
tone: ChainPillTone.warning,
|
||||
icon: Icons.gpp_maybe_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
note,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// The literal value that lands in `decided_by`, so
|
||||
// nobody is surprised by the prefix when they read
|
||||
// the log, the CLI output or a DB export.
|
||||
SelectableText(
|
||||
l.approvalsReviewerRecordedAs(
|
||||
ReviewerIdentity.wire(handle),
|
||||
),
|
||||
style: ChainTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating action bar that surfaces when the operator
|
||||
/// multi-selects pending approvals. Lets them approve or
|
||||
/// reject the whole picked set in one round-trip per item;
|
||||
|
|
@ -967,13 +1113,7 @@ class _HistoryRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
if (record.decidedBy.isNotEmpty)
|
||||
Text(
|
||||
record.decidedBy,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (record.decidedBy.isNotEmpty) _DecidedBy(record.decidedBy),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1002,6 +1142,58 @@ class _HistoryRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders a stored `decided_by`. A value Studio wrote carries the
|
||||
/// `unverified:` marker — show the readable name and keep the marker
|
||||
/// visible as a warning glyph rather than dropping either. A value
|
||||
/// without the marker (legacy row, a CLI decision) gets no badge at
|
||||
/// all: Studio does not know where it came from and must not imply
|
||||
/// it was checked.
|
||||
class _DecidedBy extends StatelessWidget {
|
||||
final String recorded;
|
||||
const _DecidedBy(this.recorded);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final parsed = ReviewerIdentity.parse(recorded);
|
||||
final name = Text(
|
||||
parsed.handle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
if (!parsed.isSelfDeclared) return name;
|
||||
return Tooltip(
|
||||
message: l.approvalsReviewerUnverifiedTooltip,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.gpp_maybe_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(child: name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "who decided" for the detail dialog's one-line summary: the
|
||||
/// readable name, and — when Studio wrote it — the plain-words
|
||||
/// hint that the attribution is an unchecked claim.
|
||||
String _decidedByLabel(BuildContext context, String recorded) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (recorded.isEmpty) return l.approvalsUnknownReviewer;
|
||||
final parsed = ReviewerIdentity.parse(recorded);
|
||||
if (!parsed.isSelfDeclared) return parsed.handle;
|
||||
return '${parsed.handle} (${l.approvalsReviewerUnverifiedPill})';
|
||||
}
|
||||
|
||||
class _HistoryDialog extends StatelessWidget {
|
||||
final ApprovalRecord record;
|
||||
const _HistoryDialog({required this.record});
|
||||
|
|
@ -1039,7 +1231,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
theme,
|
||||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}',
|
||||
'· ${_decidedByLabel(context, record.decidedBy)}',
|
||||
),
|
||||
if (record.createdAt != null)
|
||||
_kv(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||
import '../data/error_presentation.dart';
|
||||
import '../data/friendly_error.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/reviewer_identity.dart';
|
||||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -1258,7 +1259,9 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Outcome of the clear-audit confirmation dialog.
|
||||
/// Outcome of the clear-audit confirmation dialog. [reviewer] is
|
||||
/// already the wire value — the marked, unchecked claim that lands
|
||||
/// in the `chain.reset` marker (see `data/reviewer_identity.dart`).
|
||||
class _ClearOutcome {
|
||||
final String reviewer;
|
||||
final String reason;
|
||||
|
|
@ -1266,10 +1269,11 @@ class _ClearOutcome {
|
|||
}
|
||||
|
||||
/// Two-field confirmation dialog for "clear the audit log".
|
||||
/// Reviewer defaults to the OS user (closest stable identity
|
||||
/// without an auth backend); reason has no default so the
|
||||
/// operator has to type *something* — the chain.reset marker
|
||||
/// must carry context.
|
||||
/// Reviewer prefills with the local handle (the OS account is a
|
||||
/// label, not an identity — [HubService.clearEventLog] marks it as
|
||||
/// an unchecked claim on the wire, and the field's helper says so);
|
||||
/// reason has no default so the operator has to type *something* —
|
||||
/// the chain.reset marker must carry context.
|
||||
class _ClearAuditDialog extends StatefulWidget {
|
||||
const _ClearAuditDialog();
|
||||
|
||||
|
|
@ -1291,11 +1295,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'operator';
|
||||
_reviewer = TextEditingController(text: '$user@studio');
|
||||
_reviewer = TextEditingController(text: ReviewerIdentity.localHandle);
|
||||
_reason = TextEditingController();
|
||||
}
|
||||
|
||||
|
|
@ -1329,6 +1329,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
controller: _reviewer,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.auditClearReviewerLabel,
|
||||
helperText: l.auditClearReviewerHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
|
|
@ -1365,7 +1367,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
: () => Navigator.pop(
|
||||
context,
|
||||
_ClearOutcome(
|
||||
reviewer: _reviewer.text.trim(),
|
||||
reviewer: ReviewerIdentity.wire(_reviewer.text),
|
||||
reason: _reason.text.trim(),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue