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>
76 lines
3.4 KiB
C#
76 lines
3.4 KiB
C#
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"));
|
||
}
|
||
}
|