feat(settings): hub auth-policy panel — T4/T5 security parity in the GUI
Some checks failed
Security / Security check (push) Failing after 1s

Settings → Security now shows the hub's effective auth policy via the
new read-only AuthStatus RPC: active token validator (static / jwt-rs256
with issuer, audience, JWKS source), anonymous-access warning, per-token
cards with scope grants, env-var presence and rate limits, plus a
localized admin-denied story for non-admin tokens. Live-reloads on
endpoint change.

Also fixes a batch of fai→chain rename leftovers this panel's
verification uncovered: hub_auth_token.dart and registry_token.dart
read/wrote ~/.fai/ while the hub reads ~/.chain/ (stored registry
tokens never reached the hub), today_story_loader + tools/today used
~/.fai/today, chain_log legacy ~/.fai/logs migration removed per the
no-legacy-recognisers decision, and UI strings still advertised the
retired .fai bundle extension.

Includes 5 widget tests for the panel, an integration-test screenshot
harness (auth_policy_shots_test.dart, guide-shots style), and DE+EN
l10n. flutter analyze clean, 58 tests green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 03:19:33 +02:00
parent c6da5025ce
commit efaa089454
18 changed files with 1208 additions and 74 deletions

View file

@ -1,5 +1,5 @@
// Unit tests for ChainLog the central error log that
// `~/.fai/logs/studio-errors.log` is the operator-visible
// `~/.chain/logs/studio-errors.log` is the operator-visible
// surface of. The tests redirect the singleton at a temp file
// via `ChainLog.testPathOverride` so the real log under HOME is
// never touched.

View file

@ -0,0 +1,159 @@
// Settings Security hub access policy panel. Verifies the
// read-only rendering of the AuthStatus snapshot (validator,
// anonymous warning, token entries with scope grants and env-var
// state, JWT parameters) plus the live-reload affordance and the
// admin-scope refusal story all through the loader test seams,
// no hub required.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/hub_auth_policy_panel.dart';
Widget _host(HubAuthPolicyPanel panel) => MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SingleChildScrollView(child: panel)),
);
HubAuthPolicy _staticPolicy() => const HubAuthPolicy(
validator: 'static',
anonymousAllowed: false,
tokens: [
HubAuthTokenEntry(
name: 'digiscout-prod',
tokenEnv: 'CHAIN_TOKEN_DIGISCOUT',
envSet: true,
scopes: ['read', 'execute:llm.*'],
rateLimitPerMinute: 120,
),
HubAuthTokenEntry(
name: 'ci-reader',
tokenEnv: 'CHAIN_TOKEN_CI',
envSet: false,
scopes: ['read'],
rateLimitPerMinute: 0,
),
],
);
void main() {
testWidgets('static policy renders tokens, grants and env state', (
tester,
) async {
await tester.pumpWidget(
_host(HubAuthPolicyPanel(loader: () async => _staticPolicy())),
);
await tester.pumpAndSettle();
expect(
find.text('Prüfverfahren: statische Token-Liste'),
findsOneWidget,
);
expect(find.text('digiscout-prod'), findsOneWidget);
// Fine-grained T5 grant rendered verbatim as a chip.
expect(find.text('execute:llm.*'), findsOneWidget);
expect(find.text('120/min'), findsOneWidget);
expect(
find.textContaining('CHAIN_TOKEN_DIGISCOUT ist gesetzt'),
findsOneWidget,
);
// The rotation footgun is called out, not hidden.
expect(find.textContaining('CHAIN_TOKEN_CI FEHLT'), findsOneWidget);
// No anonymous warning when tokens exist.
expect(find.textContaining('anonyme Aufrufe'), findsNothing);
});
testWidgets('anonymous hub shows the plain-language warning', (
tester,
) async {
await tester.pumpWidget(
_host(
HubAuthPolicyPanel(
loader: () async => const HubAuthPolicy(
validator: 'static',
anonymousAllowed: true,
tokens: [],
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('anonyme Aufrufe'), findsOneWidget);
});
testWidgets('jwt policy renders parameters with not-checked fallback', (
tester,
) async {
await tester.pumpWidget(
_host(
HubAuthPolicyPanel(
loader: () async => const HubAuthPolicy(
validator: 'jwt-rs256',
anonymousAllowed: false,
tokens: [],
jwt: HubJwtValidatorInfo(
keySource: '/etc/chain/idp.pem',
audience: 'chain-hub',
issuer: '',
scopeClaim: 'scope',
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.textContaining('JWT (RS256)'),
findsOneWidget,
);
expect(find.text('/etc/chain/idp.pem'), findsOneWidget);
expect(find.text('chain-hub'), findsOneWidget);
// Empty issuer honest "not checked", not an empty cell.
expect(find.text('wird nicht geprüft'), findsOneWidget);
});
testWidgets('reload button reports the new token count', (tester) async {
var reloads = 0;
await tester.pumpWidget(
_host(
HubAuthPolicyPanel(
loader: () async => _staticPolicy(),
reloader: () async {
reloads++;
return 2;
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Tokens neu laden'));
await tester.pumpAndSettle();
expect(reloads, 1);
expect(find.text('Neu geladen — 2 Token aktiv.'), findsOneWidget);
});
testWidgets('permission denied maps to the admin-scope story', (
tester,
) async {
await tester.pumpWidget(
_host(
HubAuthPolicyPanel(
loader: () async =>
throw Exception('gRPC Error (code: 7, codeName: PERMISSION_DENIED)'),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('admin-Recht'), findsOneWidget);
expect(find.text('Erneut versuchen'), findsOneWidget);
});
}

View file

@ -1,6 +1,6 @@
// Hub fixture for integration-level tests.
//
// Spawns a real `fai serve` subprocess on a free port in a
// Spawns a real `chain serve` subprocess on a free port in a
// temporary directory, waits for its gRPC endpoint to answer
// `Healthy`, and exposes the port + a teardown so test files
// can talk to a clean Hub without setting up an isolate-side
@ -8,13 +8,13 @@
//
// Cost / scope notes:
//
// * The `fai` binary must be on the operator's PATH or at
// `target/release/fai` relative to the platform repo. We
// * The `chain` binary must be on the operator's PATH or at
// `target/release/chain` relative to the platform repo. We
// resolve those two paths in order; if neither exists, the
// fixture skips the suite with a clear message rather than
// failing these tests are opt-in.
// * The hub is started with `--quiet` (no log spam) against a
// temp dir, so the operator's real `~/.fai/` stays untouched.
// temp dir, so the operator's real `~/.chain/` stays untouched.
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
// after a grace period. The temp dir is removed.
// * Each fixture is independent call `HubFixture.start()` in
@ -59,6 +59,13 @@ class HubFixture {
/// `markSkipped` instead of failing when the binary isn't
/// present that's the desired behaviour in CI environments
/// that don't build the binary first.
///
/// [configYaml], when given, is written to `<tempDir>/config.yaml`
/// and handed to the hub via `$CHAIN_CONFIG` tests can exercise
/// operator policy (e.g. `auth.tokens`) without touching the real
/// `~/.chain/config.yaml`. Token *values* go through
/// [extraEnvironment]; pass the matching [authToken] so the
/// readiness poll (and [client]) authenticate against the hub.
static Future<HubFixture?> start({
// A cold hub spends its first 30s building the curated-
// model database + initialising SQLite. 60s gives that
@ -66,6 +73,9 @@ class HubFixture {
// a slow one.
Duration readyTimeout = const Duration(seconds: 60),
bool skipIfBinaryMissing = true,
String? configYaml,
Map<String, String> extraEnvironment = const {},
String? authToken,
}) async {
final binary = await _resolveBinary();
if (binary == null) {
@ -84,10 +94,16 @@ class HubFixture {
final port = await _pickFreePort();
final addr = '127.0.0.1:$port';
String? configPath;
if (configYaml != null) {
configPath = '${tempDir.path}/config.yaml';
File(configPath).writeAsStringSync(configYaml);
}
// Run as a child process. `serve --bind` lets us avoid the
// default 50051 (and any local daemon the developer happens
// to be running). CHAIN_DATA_DIR keeps the temp footprint
// contained no state leaks into ~/.fai.
// contained no state leaks into ~/.chain.
final process = await Process.start(
binary,
['serve', '--bind', addr],
@ -95,6 +111,8 @@ class HubFixture {
...Platform.environment,
'CHAIN_DATA_DIR': tempDir.path,
'CHAIN_MODULES_DIR': '${tempDir.path}/modules',
'CHAIN_CONFIG': ?configPath,
...extraEnvironment,
// Suppress info logs without a CLI flag the binary
// respects RUST_LOG for tracing-subscriber.
'RUST_LOG': 'warn',
@ -111,6 +129,7 @@ class HubFixture {
// a hung hub is a test failure, not a hang.
final client = HubClient(
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
authToken: authToken,
);
final deadline = DateTime.now().add(readyTimeout);
while (DateTime.now().isBefore(deadline)) {
@ -136,7 +155,7 @@ class HubFixture {
await tempDir.delete(recursive: true);
throw StateError(
'Hub did not become healthy within $readyTimeout. '
'Check `fai serve` works manually with CHAIN_DATA_DIR=$tempDir.',
'Check `chain serve` works manually with CHAIN_DATA_DIR=$tempDir.',
);
}