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
+19 -11
View File
@@ -641,15 +641,20 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
}
// buildLimitScopes returns the limitScope slice runUnified passes to the
// limiter: a user scope and a group scope, ALWAYS both, unconditionally
// (v0.21 fix — see this function's doc comment history below for the bug
// this closes). The user scope is listed first, so checkAndCount reports a
// user's own violation ahead of their group's when both are breached by the
// same request.
// limiter: a user scope and one group scope PER MEMBER GROUP (grp.
// memberScopeGroups, auth.go — one for an ordinary, single-membership
// group, more for a multi-group principal; UserConfig.Groups' own "each
// member group's own limits apply as its own limit scope" rule,
// llmgateway.go), ALWAYS all of them, unconditionally (v0.21 fix — see
// this function's doc comment history below for the bug this closes).
// The user scope is listed first, so checkAndCount reports a user's own
// violation ahead of their group's when both are breached by the same
// request.
//
// u.limits or grp.limits may be nil — that scope simply carries no limit for
// checkAndCount (limits.go) to enforce, and checkAndCount's own nil-limits
// check skips it during evaluation. It is NOT omitted from the slice: usage
// u.limits or a member group's own limits may be nil — that scope simply
// carries no limit for checkAndCount (limits.go) to enforce, and
// checkAndCount's own nil-limits check skips it during evaluation. It is
// NOT omitted from the slice: usage
// accounting (checkAndCount's own counting half, and account) is
// unconditional and must never be coupled to whether a limit happens to be
// configured. Production bug (root-caused live, 2026-08-21): the previous
@@ -675,10 +680,13 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
// handleAdminAPI "calls this directly, without withTotalScope" — it never
// called this function at all, on any version of this file.
func buildLimitScopes(u *user, grp *group) []limitScope {
return []limitScope{
{limits: u.limits, kind: "user", id: u.name},
{limits: grp.limits, kind: "group", id: grp.name},
members := grp.memberScopeGroups()
scopes := make([]limitScope, 0, 1+len(members))
scopes = append(scopes, limitScope{limits: u.limits, kind: "user", id: u.name})
for _, mg := range members {
scopes = append(scopes, limitScope{limits: mg.limits, kind: "group", id: mg.name})
}
return scopes
}
// withTotalScope returns scopes with the synthetic total scope