diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go new file mode 100644 index 000000000..46732bd3b --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go @@ -0,0 +1,41 @@ +package main + +// A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written +// in the characters a key of the bus takes. The key is the store's alone. + +import ( + "context" + "strings" +) + +// KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character +// outside letters, digits, `-` and `.` written as `_`. +func KeyFor(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case r == '@': + b.WriteString("_at_") + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// keyTaken names the licence other than name whose key is name's, or "" when none is. +func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) { + all, err := m.Store.Licences(ctx) + if err != nil { + return "", err + } + key := KeyFor(name) + for _, l := range all { + if l.Name != name && KeyFor(l.Name) == key { + return l.Name, nil + } + } + return "", nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go new file mode 100644 index 000000000..29e0e36c3 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// design 39 bus-and-switch §10, B6. +func TestALicencesKeyForm(t *testing.T) { + for name, key := range map[string]string{ + "jane@example.org": "jane_at_example.org", + "Jane.Doe@Example.ORG": "jane.doe_at_example.org", + "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", + "team key_1": "team_key_1", + "a+b/c:d": "a_b_c_d", + "ops-key": "ops-key", + } { + if got := KeyFor(name); got != key { + t.Errorf("KeyFor(%q) = %q, want %q", name, got, key) + } + } +} + +// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event. +func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) { + ctx := context.Background() + t.Run("an API key", func(t *testing.T) { + mm := newMesh(t) + _ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"}) + _, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test") + if err == nil || !strings.Contains(err.Error(), "Ops-Key") { + t.Fatalf("adopted a key whose key form is taken: %v", err) + } + if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil { + t.Fatal("the clashing licence was kept") + } + assertRefused(t, mm) + }) + t.Run("a login", func(t *testing.T) { + mm := newMesh(t) + _ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"}) + r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute)) + adopted, err := mm.m.Consider(ctx, []Holdings{r}) + if err != nil || len(adopted) != 0 { + t.Fatalf("adopted %v (%v), want none", adopted, err) + } + if len(mm.vendor.exchanged) != 0 { + t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged) + } + if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused { + t.Fatalf("outcome %q, want refused", o) + } + assertRefused(t, mm) + }) +} + +func assertRefused(t *testing.T, mm *miniMesh) { + t.Helper() + said := false + for _, e := range mm.events { + said = said || strings.HasPrefix(e, "licence.refused ") + } + audited := false + for _, a := range mm.store.Audits { + audited = audited || a["what"] == "refused" + } + if !said || !audited { + t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index fe8b208cd..1fa28f126 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -348,6 +348,24 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) return "", nil } + // A new licence whose key another licence already has is refused before its grant is exchanged + // (design 39 bus-and-switch §10). + if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil { + return "", err + } else if held == nil { + taken, err := m.keyTaken(ctx, NameFor(c.Identity)) + if err != nil { + return "", err + } + if taken != "" { + why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken + _ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why) + _ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}) + _ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why}) + return "", nil + } + } + // Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the // only holder of a live refresh token for the account. r := m.Vendor.Refresh(ctx, offered) @@ -751,6 +769,16 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str if held != nil && held.Kind != "api-key" { return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name) } + taken, err := m.keyTaken(ctx, name) + if err != nil { + return nil, err + } + if taken != "" { + why := "its key " + KeyFor(name) + " is the key of the licence " + taken + _ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}) + _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) + return nil, fmt.Errorf("%s is refused: %s", name, why) + } l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()} if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err