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>
This commit is contained in:
Richard
2026-10-01 12:50:27 +02:00
co-authored by Claude Opus 5.5
parent 661807b2d0
commit 9a265888f8
17 changed files with 306 additions and 13 deletions
@@ -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"));
}
}