2 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 edf3ff19ea Merge feat/692-unit-credentials: Zugangsdaten je Einheit (#692)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:57:56 +02:00
RichardandClaude Opus 5.5 9a265888f8 Werkzeugkasten: Zugangsdaten je Einheit (#692)
A unit holds credentials for its members, by tool and setting - needed once,
not once per agent, and other units never get them.

- UnitConfig.credentials, protected by ConfigSecrets like every secret.
- OrgChart.ToolSettingsFor: an agent's own settings, completed by the nearest
  unit up that holds the value; own non-empty values win; no tool is granted.
  Used for runs, tool jobs and preflight probes.
- OrgAdministration.SetUnitCredential, PUT /organisation/units/{id}/credentials;
  the view lists held settings, never values.
- Templates never carry unit credentials; they are named instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:50:27 +02:00
17 changed files with 306 additions and 13 deletions
+9
View File
@@ -784,6 +784,15 @@ beidem.
braucht sie einmal, nicht sechsmal — und die Buchhaltung darf den Marktplatz-Schlüssel
nicht sehen. Gehört an die Einheit (#683), mit demselben Vererbungsgedanken wie die
Rechte.
> **Umgesetzt (#692).** Eine Einheit hält Zugangsdaten je Werkzeug und Einstellung
> (`credentials`), geschützt gespeichert wie alle Geheimnisse und in der Oberfläche nur
> als „gesetzt" sichtbar. Ein Werkzeug eines Mitglieds — auch einer Untereinheit — läuft
> mit seinen eigenen Einstellungen, ergänzt um die der nächsten Einheit nach oben, die den
> Wert hält; ein eigener, nicht leerer Wert des Agenten gewinnt. Ein Zugangsdatum gibt
> kein Werkzeug, und Nachbareinheiten bekommen nichts. Gilt für Läufe, Tool-Jobs und
> Vorprüfungen gleichermaßen. Nur Einstellungen der obersten Ebene — verschachtelte wie
> `providers.x.apiKey` bleiben am Agenten. Vorlagen (#691) tragen sie nie mit.
2. **Das Lesen dessen, was wirklich hereinkommt.** Eine Auftragsbestätigung ist ein
PDF-Anhang, eine Preisliste eine Tabelle. Text aus Anhängen zu holen ist die
unspektakulärste und am häufigsten gebrauchte fehlende Fähigkeit. Ein PDF vom
+1 -3
View File
@@ -796,9 +796,7 @@ public sealed class AppHost : IAsyncDisposable
if (agent is null)
return null;
var toolConfig = agent.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var toolConfig = Core.Organisation.OrgChart.From(instance).ToolSettingsFor(agent, toolName);
return new AgentToolContext(
agent.AgentId,
@@ -271,6 +271,44 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger)
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
}
// ─── credentials per unit (#692) ───
/// <summary>
/// #692: Sets or removes one credential a unit holds for its members' tool. The value is
/// written protected and never shown again — the audit names the setting, not the value.
/// </summary>
public AdminResult SetUnitCredential(string unitId, string tool, string key, string? value, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
tool = (tool ?? "").Trim();
key = (key ?? "").Trim();
if (tool.Length == 0 || key.Length == 0)
return AdminResult.Failure("Name the tool and the setting the credential is for.");
unit.Credentials ??= new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
var toolKey = unit.Credentials.Keys.FirstOrDefault(k => Same(k, tool)) ?? tool;
if (!unit.Credentials.TryGetValue(toolKey, out var held))
unit.Credentials[toolKey] = held = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var remove = string.IsNullOrEmpty(value);
if (remove)
{
held.Remove(held.Keys.FirstOrDefault(k => Same(k, key)) ?? key);
if (held.Count == 0)
unit.Credentials.Remove(toolKey);
if (unit.Credentials.Count == 0)
unit.Credentials = null;
}
else
held[held.Keys.FirstOrDefault(k => Same(k, key)) ?? key] = value!;
Save(null, "unit_credential", new { unitId, tool = toolKey, key, set = !remove }, reason);
return AdminResult.Success(remove
? $"'{unit.Name}' no longer holds {toolKey}.{key}."
: $"'{unit.Name}' holds {toolKey}.{key} for its members and the units below — where an agent has {toolKey} and no value of its own.");
}
// ─── votes (#702) ───
/// <summary>
@@ -1004,7 +1042,7 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger)
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals,
Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin
Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin, Credentials = u.Credentials
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
@@ -74,6 +74,15 @@ public sealed class UnitConfig
/// <summary>#691: The template the unit came from — its texts and rules are someone else's.</summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
/// <summary>
/// #692: Credentials the unit holds for its members, by tool and setting — a purchasing
/// department with six agents needs the marketplace key once, not six times, and the
/// accounts department never gets it. Every value is a secret, stored protected; a member's
/// own non-empty value wins, and a credential grants no tool.
/// </summary>
[JsonPropertyName("credentials")]
public Dictionary<string, Dictionary<string, string>>? Credentials { get; set; }
}
/// <summary>
+4 -1
View File
@@ -1853,7 +1853,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null, string? runId = null)
{
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
// #692: completed by the credentials the agent's unit holds for the tool.
var toolConfig = OrganisationProvider?.Invoke() is { } org
? org.ToolSettingsFor(agentConfig, toolName)
: agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
@@ -135,6 +135,43 @@ public sealed class OrgChart
: null;
}
/// <summary>
/// #692: The settings a tool of this agent runs with — its own, completed by the credentials
/// its unit and the units above hold for that tool (the nearest first). The agent's own
/// non-empty value wins; nothing is added for a tool the agent does not have.
/// </summary>
public IReadOnlyDictionary<string, object?> ToolSettingsFor(AgentConfig agent, string toolName)
{
if (!agent.Tools.TryGetValue(toolName, out var own))
return new Dictionary<string, object?>().AsReadOnly();
if (UnitOf(agent.AgentId) is not { } unit)
return own.AsReadOnly();
Dictionary<string, object?>? merged = null;
foreach (var holder in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (holder.Credentials?.FirstOrDefault(c => string.Equals(c.Key, toolName, StringComparison.OrdinalIgnoreCase)).Value is not { } held)
continue;
foreach (var (key, value) in held)
{
merged ??= new Dictionary<string, object?>(own, StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(value) || (merged.TryGetValue(key, out var current) && !IsEmpty(current)))
continue;
merged[key] = value;
}
}
return (merged ?? own).AsReadOnly();
}
private static bool IsEmpty(object? value) => value switch
{
null => true,
string s => s.Length == 0,
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } e => string.IsNullOrEmpty(e.GetString()),
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined } => true,
_ => false
};
/// <summary>The unit this agent leads, if any.</summary>
public UnitConfig? LedBy(string agentId)
{
@@ -99,6 +99,9 @@ public sealed record CredentialNeed(
public static class OrgTemplates
{
/// <summary>A credential need held by a unit, not an agent: <c>unit:&lt;id&gt;</c>.</summary>
public const string UnitPrefix = "unit:";
/// <summary>Longest Identity or Soul text a template may bring — it goes into every prompt.</summary>
public const int MaxTextLength = 50_000;
@@ -121,7 +124,14 @@ public static class OrgTemplates
foreach (var unit in instance.Units)
{
template.Units.Add(JsonSerializer.Deserialize<UnitConfig>(JsonSerializer.Serialize(unit))!);
var copy = JsonSerializer.Deserialize<UnitConfig>(JsonSerializer.Serialize(unit))!;
// #692: what a unit holds for its members stays here — named, never carried.
foreach (var (tool, held) in copy.Credentials ?? [])
foreach (var key in held.Where(h => h.Value.Length > 0).Select(h => h.Key))
template.Credentials.Add(new CredentialNeed(UnitPrefix + unit.Id, tool, key));
copy.Credentials = null;
copy.TemplateOrigin = null;
template.Units.Add(copy);
if (doctrineOf(unit.Id) is { Length: > 0 } doctrine)
template.Doctrines[unit.Id] = doctrine;
}
@@ -225,6 +235,8 @@ public static class OrgTemplates
problems.Add($"Unit '{u.Id}' appears twice.");
if (string.IsNullOrWhiteSpace(u.Name))
problems.Add($"Unit '{u.Id}' has no name.");
if (u.Credentials?.Values.Any(h => h.Values.Any(v => v.Length > 0)) == true)
problems.Add($"Unit '{u.Id}' brings credentials — a template never carries one.");
}
foreach (var u in template.Units.Where(u => u.ParentId is not null && !units.Contains(u.ParentId)))
problems.Add($"Unit '{u.Id}' hangs under '{u.ParentId}', which the template does not have.");
@@ -63,6 +63,13 @@ public static class ConfigSecrets
foreach (var agent in config.Agents)
Apply(agent, transform);
// #692: everything a unit holds for its members is a secret, whatever its name.
foreach (var unit in config.Units)
foreach (var settings in unit.Credentials?.Values ?? Enumerable.Empty<Dictionary<string, string>>())
foreach (var key in settings.Keys.ToList())
if (settings[key] is { Length: > 0 } value)
settings[key] = transform(value) ?? "";
}
// ─── Agent ───
@@ -159,7 +159,12 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
var agentId = TaskAssignee.AgentId(task.Assignee);
var config = _agents().FirstOrDefault(a => a.AgentId == agentId);
var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg)
// #692: completed by the credentials the agent's unit holds for the tool.
var toolConfig = config is null
? new Dictionary<string, object?>().AsReadOnly()
: _engine.OrganisationProvider?.Invoke() is { } org
? org.ToolSettingsFor(config, task.ToolName)
: config.Tools.TryGetValue(task.ToolName, out var cfg)
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
@@ -100,6 +100,14 @@
"org.approvalsOperations": "Operationen",
"org.approvalsPerDay": "Höchstens je Tag",
"org.approvalsHours": "Stunden je Freigabe",
"org.credentials": "Zugangsdaten der Einheit",
"org.credentialsHint": "Zugangsdaten, die die Einheit für ihre Mitglieder und die Einheiten darunter hält — einmal gebraucht, nicht einmal je Agent, und andere Einheiten bekommen sie nie. Ein Mitglied nutzt sie nur für ein Werkzeug, das es hat, und nur, wo es keinen eigenen Wert trägt. Werte werden geschützt gespeichert und nie wieder angezeigt.",
"org.credentialsNone": "Die Einheit hält keine Zugangsdaten.",
"org.credentialSet": "gesetzt",
"org.credentialRemove": "Entfernen",
"org.credentialTool": "Werkzeug",
"org.credentialKey": "Einstellung",
"org.credentialValue": "Wert",
"org.doctrine": "Doktrin",
"org.doctrineHint": "Was in dieser Einheit gerade gilt — Rahmen für jede Aufgabe ihrer Mitglieder, von ihrer Leitung ohne Rückfrage gesetzt. Sie erreicht nur die Mitglieder dieser Einheit, nicht die Untereinheiten; die haben ihre eigene. Kurz halten: Sie geht in jeden Lauf. Leer entfernt sie.",
"org.doctrinePlaceholder": "Jeder Lieferantenkontakt wird in der Datenbank erfasst, bevor er weitergegeben wird.",
@@ -100,6 +100,14 @@
"org.approvalsOperations": "Operations",
"org.approvalsPerDay": "Per day at most",
"org.approvalsHours": "Hours per approval",
"org.credentials": "Credentials of the unit",
"org.credentialsHint": "Credentials the unit holds for its members and the units below — needed once, not once per agent, and other units never get them. A member uses them only for a tool it has, and only where it has no value of its own. Values are stored protected and never shown again.",
"org.credentialsNone": "The unit holds no credentials.",
"org.credentialSet": "set",
"org.credentialRemove": "Remove",
"org.credentialTool": "Tool",
"org.credentialKey": "Setting",
"org.credentialValue": "Value",
"org.doctrine": "Doctrine",
"org.doctrineHint": "What holds in this unit now — a frame for every task of its members, set by its lead without asking. It reaches this unit's members only, not the sub-units; they have their own. Short: it goes into every run. Empty removes it.",
"org.doctrinePlaceholder": "Every supplier contact is entered in the database before it is passed on.",
@@ -482,6 +482,7 @@ function unitPanel(unit) {
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(credentialsCard(unit));
cards.push(approvalsCard(unit));
cards.push(doctrineCard(unit));
cards.push(inspectionCard(unit));
@@ -552,6 +553,43 @@ function ceilingCard(unit) {
]);
}
/**
* #692: Credentials the unit holds for its members — one marketplace key for six agents, and
* the accounts department never gets it. Values are written, never shown: only which settings
* are held is listed.
*/
function credentialsCard(unit) {
const held = unit.credentialKeys ?? {};
const tool = erstelleElement("input", { type: "text", placeholder: "DirectAPI" });
const key = erstelleElement("input", { type: "text", placeholder: "apiKey" });
const value = erstelleElement("input", { type: "password", autocomplete: "new-password" });
const path = `/organisation/units/${encodeURIComponent(unit.id)}/credentials`;
const rows = Object.entries(held).flatMap(([t1, keys]) => keys.map((k) => erstelleElement("li", { klasse: "gruppe-horizontal text-klein" },
erstelleElement("span", { text: `${t1}.${k}` }),
erstellePlakette(t("org.credentialSet"), "erfolg"),
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.credentialRemove"),
onclick: () => send(path, "PUT", { tool: t1, key: k, value: null, reason: reason() })
}))));
return erstelleKarte(t("org.credentials"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsHint") }),
rows.length ? erstelleElement("ul", { klasse: "org-liste" }, ...rows) : erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsNone") }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.credentialTool") }), tool,
erstelleElement("label", { text: t("org.credentialKey") }), key,
erstelleElement("label", { text: t("org.credentialValue") }), value),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: async () => {
const ok = await send(path, "PUT", { tool: tool.value.trim(), key: key.value.trim(), value: value.value, reason: reason() });
if (ok) value.value = "";
}
})
]);
}
/**
* #687: What the unit's lead may approve instead of the human. One operation per line —
* "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server.
@@ -24,7 +24,9 @@ public sealed record UnitDto(
// #693: the lead's spot checks; null: none.
UnitInspectionDto? Inspection = null,
// #702: what the unit puts to a blind vote; null: nothing.
UnitVotesDto? Votes = null);
UnitVotesDto? Votes = null,
// #692: the credentials the unit holds, by tool — names only, never values.
IReadOnlyDictionary<string, IReadOnlyList<string>>? CredentialKeys = null);
/// <summary>#698: One version of a unit's doctrine.</summary>
public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason);
@@ -42,6 +44,9 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
public sealed record FindingDto(string Kind, string Text);
/// <summary>#692: One credential a unit holds; an empty value removes it.</summary>
public sealed record UnitCredentialInputDto(string Tool, string Key, string? Value, string Reason = "");
/// <summary>#691: A template to preview or import, with what the human changes on the way in.</summary>
public sealed record TemplateImportDto(
System.Text.Json.JsonElement Template,
@@ -30,7 +30,10 @@ internal static class OrganisationEndpoints
chart.PathOf(u.Id), chart.Depth(u.Id),
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null,
u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null,
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null))
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null,
u.Credentials is { Count: > 0 } held
? held.ToDictionary(h => h.Key, h => (IReadOnlyList<string>)h.Value.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList())
: null))
.ToList();
var agents = host.Instance.Agents
@@ -105,6 +108,10 @@ internal static class OrganisationEndpoints
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
});
// #692: Credentials a unit holds for its members — written here, never read back.
org.MapPut("/units/{unitId}/credentials", (AppHost host, string unitId, UnitCredentialInputDto input) =>
host.Organisation.SetUnitCredential(unitId, input.Tool, input.Key, input.Value, input.Reason).ToResponse());
// #691: The organisation as a template file — exported without credentials, imported only after a preview and a confirmation.
org.MapGet("/template", (AppHost host, string? name, string? description) =>
Results.Text(System.Text.Json.JsonSerializer.Serialize(
@@ -330,6 +330,22 @@ public sealed class OrgAdministrationTests : IDisposable
Unit("marketing").Votes.ShouldBeNull();
}
// ═══════════════════ credentials per unit (#692) ═══════════════════
[Fact]
public void A_unit_holds_a_credential_until_it_is_removed()
{
BuildMarketing();
Org.SetUnitCredential("marketing", "", "apiKey", "x").Ok.ShouldBeFalse();
Org.SetUnitCredential("marketing", "WebFetch", "token", "t-1", "shared key").Ok.ShouldBeTrue();
Unit("marketing").Credentials!["WebFetch"]["token"].ShouldBe("t-1");
Org.Current().ToolSettingsFor(Find("ads"), "WebFetch")["token"].ShouldBe("t-1", "ads sits in amazon, below marketing");
Org.SetUnitCredential("marketing", "webfetch", "TOKEN", null).Ok.ShouldBeTrue();
Unit("marketing").Credentials.ShouldBeNull();
}
// ═══════════════════ templates (#691) ═══════════════════
private static Core.Organisation.OrgTemplate Shop() => new()
@@ -0,0 +1,75 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #692: A unit holds credentials for its members — needed once, not six times, and the
/// accounts department never gets the marketplace key.
/// </summary>
public sealed class UnitCredentialsTests
{
/// <summary>shop (holds DirectAPI.apiKey, Mail.password) › buying (holds DirectAPI.apiKey); accounts beside it.</summary>
private static OrgChart Org(params AgentConfig[] agents) => new(
[
new UnitConfig
{
Id = "shop", Name = "Shop",
Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "shop-key" }, ["Mail"] = new() { ["password"] = "shop-mail" } }
},
new UnitConfig { Id = "buying", Name = "Buying", ParentId = "shop", Credentials = new() { ["directapi"] = new() { ["apiKey"] = "buying-key" } } },
new UnitConfig { Id = "accounts", Name = "Accounts" }
],
agents);
private static AgentConfig Agent(string id, string unit, params (string Tool, Dictionary<string, object?> Settings)[] tools)
=> new() { AgentId = id, UnitId = unit, Tools = tools.ToDictionary(t => t.Tool, t => t.Settings) };
[Fact]
public void A_member_gets_the_nearest_units_credential_for_a_tool_it_has()
{
var buyer = Agent("buyer", "buying", ("DirectAPI", new() { ["baseUrl"] = "https://api.example" }), ("Mail", new()));
var org = Org(buyer);
org.ToolSettingsFor(buyer, "DirectAPI")["apiKey"].ShouldBe("buying-key", "the nearest unit first");
org.ToolSettingsFor(buyer, "DirectAPI")["baseUrl"].ShouldBe("https://api.example");
org.ToolSettingsFor(buyer, "Mail")["password"].ShouldBe("shop-mail", "from the unit above");
}
[Fact]
public void Its_own_value_wins_and_an_empty_one_is_filled()
{
var own = Agent("own", "shop", ("DirectAPI", new() { ["apiKey"] = "own-key" }), ("Mail", new() { ["password"] = "" }));
var org = Org(own);
org.ToolSettingsFor(own, "DirectAPI")["apiKey"].ShouldBe("own-key");
org.ToolSettingsFor(own, "Mail")["password"].ShouldBe("shop-mail");
}
[Fact]
public void No_credential_reaches_another_unit_or_a_tool_the_agent_lacks()
{
var clerk = Agent("clerk", "accounts", ("DirectAPI", new()));
var buyer = Agent("buyer", "buying", ("FileRW", new()));
var org = Org(clerk, buyer);
org.ToolSettingsFor(clerk, "DirectAPI").ContainsKey("apiKey").ShouldBeFalse();
org.ToolSettingsFor(buyer, "DirectAPI").ShouldBeEmpty("a credential grants no tool");
}
[Fact]
public void A_template_never_carries_what_a_unit_holds()
{
var instance = new InstanceConfig();
instance.Units.Add(new UnitConfig { Id = "shop", Name = "Shop", Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "shop-key" } } });
var template = OrgTemplates.Export(instance, "Shop", "", _ => null, DateTime.UtcNow);
template.Units[0].Credentials.ShouldBeNull();
template.Credentials.ShouldContain(new CredentialNeed("unit:shop", "DirectAPI", "apiKey"));
template.Units[0].Credentials = new() { ["DirectAPI"] = new() { ["apiKey"] = "smuggled" } };
OrgTemplates.Problems(template).ShouldContain(p => p.Contains("brings credentials"));
}
}
@@ -281,6 +281,24 @@ public sealed class ConfigSecretsTests : IDisposable
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("mail-passwort");
}
[Fact]
public void Credentials_a_unit_holds_are_protected_whatever_their_name()
{
// #692: everything a unit holds for its members is a secret.
var instance = new InstanceConfig();
instance.Units.Add(new UnitConfig
{
Id = "purchasing", Name = "Purchasing",
Credentials = new() { ["DirectAPI"] = new() { ["marketplaceKey"] = "mk-123" } }
});
ConfigSecrets.Protect(instance);
instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldNotContain("mk-123");
ConfigSecrets.Unprotect(instance);
instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldBe("mk-123");
}
[Fact]
public void Nicht_geheime_Felder_bleiben_lesbar()
{