feat(auth): let a user join several groups and hold a personal grant

A user entry can now list several groups in "groups", next to the
existing "group" field. A user can also hold a personal grant with its
own "providers" and optional "models" lists. Users with one group and
no personal grant behave exactly as before.

A request is allowed only when one grant allows the provider, the model
and, for passthrough, the path. Grants never merge, so one grant cannot
supply the provider while another supplies the model or the path. The
personal grant uses the path list of the first group. A personal
"models" list without "providers" is a configuration error.

Each member group applies its own limits. Caching stays on unless a
member group turns it off, and the shortest group TTL wins. The
/v1/models cache uses a stable, length-prefixed key, so reloads do not
grow it.

Tests cover the grant union, cross-grant denials, passthrough path and
model coupling, reloads and the cache key. The yaegi-check harness
asserts the same cases under the interpreter.

Claude-Session: https://claude.ai/code/session_019uJHQKcNLMACkXssb3L7Q3
This commit is contained in:
2026-09-11 22:45:45 +01:00
parent 8b49bbf2a7
commit 72b0df5b0c
17 changed files with 2894 additions and 156 deletions
+36
View File
@@ -1080,6 +1080,42 @@ func TestAdminUsage_GroupAccessLists(t *testing.T) {
}
}
// TestAdminUsage_MultiGroupUser_ListsAllGroupsInJSON proves the admin
// usage JSON view (adminUsageEntryView.Groups) lists every member group
// for a multi-group user, not just the first — GroupName (singular)
// keeps the first group for back-compat.
func TestAdminUsage_MultiGroupUser_ListsAllGroupsInJSON(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.Admin = &AdminConfig{Enabled: true}
cfg.Providers = map[string]*ProviderConfig{
"alpha": {Type: "openai", BaseURL: "http://alpha.invalid", APIKey: "sk-alpha"},
}
cfg.Groups = map[string]*GroupConfig{"eng": {}, "ops": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{
{Name: "carol", Group: "eng", Groups: []string{"ops"}, APIKey: "sk-carol"},
{Name: "admin1", Group: "eng", APIKey: "sk-admin1", Admin: true},
}}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminUsageResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
carol := findUsageEntry(t, got.Users, "carol")
if carol.GroupName != "eng" {
t.Errorf("carol.GroupName = %q, want %q (first group, back-compat)", carol.GroupName, "eng")
}
if !slices.Equal(carol.Groups, []string{"eng", "ops"}) {
t.Errorf("carol.Groups = %v, want [eng ops]", carol.Groups)
}
}
// TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's
// fail-closed path directly: a configured store whose every operation
// errors, with failOpen false, must report storeDown and zero values