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
+54
View File
@@ -656,6 +656,60 @@ func TestEffectiveTTL_NilInheritsGlobal(t *testing.T) {
// TestEffectiveTTL_GroupOverrideHonored proves a group's own resolved
// cacheTTL wins over the cache's global TTL.
// --- effectiveGroup: multi-group cache merge rule ---
// TestEffectiveGroup_CacheMerge_FalseWinsOverTrue proves the multi-group
// cache-merge rule (UserConfig.Groups' own doc comment, llmgateway.go):
// if ANY member group sets cache false, the synthetic principal's own
// effective cache setting is false, even when another member sets it
// true.
func TestEffectiveGroup_CacheMerge_FalseWinsOverTrue(t *testing.T) {
no, yes := false, true
a := &group{name: "a", cache: &yes}
b := &group{name: "b", cache: &no}
grp := effectiveGroup([]*group{a, b}, nil)
if groupCacheEnabled(grp) {
t.Error("groupCacheEnabled = true, want false (one member group opts out)")
}
}
// TestEffectiveGroup_CacheMerge_TrueWinsOverNil proves true wins when no
// member group sets false but at least one sets true.
func TestEffectiveGroup_CacheMerge_TrueWinsOverNil(t *testing.T) {
yes := true
a := &group{name: "a", cache: &yes}
b := &group{name: "b"} // nil: inherit
grp := effectiveGroup([]*group{a, b}, nil)
if !groupCacheEnabled(grp) {
t.Error("groupCacheEnabled = false, want true (one member group opts in, none opts out)")
}
}
// TestEffectiveGroup_CacheMerge_AllNilInherits proves nil-inherit stays
// nil-inherit when no member group sets Cache at all.
func TestEffectiveGroup_CacheMerge_AllNilInherits(t *testing.T) {
a := &group{name: "a"}
b := &group{name: "b"}
grp := effectiveGroup([]*group{a, b}, nil)
if grp.cache != nil {
t.Errorf("grp.cache = %v, want nil (no member group sets it)", grp.cache)
}
}
// TestEffectiveGroup_CacheTTLMerge_SmallestPositiveWins proves the
// multi-group cacheTTL-merge rule: the SMALLEST positive cacheTTL among
// member groups wins; a member that leaves cacheTTL unset (0) must never
// win as "smallest".
func TestEffectiveGroup_CacheTTLMerge_SmallestPositiveWins(t *testing.T) {
a := &group{name: "a", cacheTTL: 30 * time.Second}
b := &group{name: "b", cacheTTL: 5 * time.Minute}
c := &group{name: "c"} // 0: unset
grp := effectiveGroup([]*group{a, b, c}, nil)
if grp.cacheTTL != 30*time.Second {
t.Errorf("grp.cacheTTL = %v, want 30s (the smallest positive among member groups)", grp.cacheTTL)
}
}
func TestEffectiveTTL_GroupOverrideHonored(t *testing.T) {
c := &responseCache{ttl: 5 * time.Minute}
grp := &group{name: "g", cacheTTL: 30 * time.Second}