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>
139 lines
5.4 KiB
Dart
139 lines
5.4 KiB
Dart
// 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,
|
|
);
|
|
}
|
|
}
|